The Micro Frontend Chaos (and how to solve it)

By ZoomInfo Engineering, March 2, 2022

Micro frontends are the missing piece for a fully isolated, multi-application platform to provide the flexibility and agility we need, but at what cost?

By Orel Balilti

Why do we need a micro frontend?

During the past decade, complexity has increased in frontend applications. Much of the business logic is no longer only on the backend, but also found on the client-side. This affects the application complexity and introduces a new monolith —the frontend application. 

Since front-end applications are generally developed, deployed and hosted as a single application, layers of complexity across multiple domains of responsibility becomes harder and harder to manage. In a well-structured code-base, directories are leveraged to separate independent modules, libraries, and sometimes even applications. A micro front-end would allow us to tap into the already built logical code separation, but also in the development and deployment stages, as well as the content served to the end-user in their browser. It would provide the following advantages:

  • Loosely coupled applications
  • Faster development, debugging, and testing flows
  • Performance — smaller chunks
  • Full Isolation while testing, developing, and deploying

E2E Domains responsibility by team


Overview

We’re going to develop together a frontend application that is built out of a shell and 3 micro frontend applications that represent a domain:

  • Shell  —  The shell is used as the entry point for loading each of our micro applications based on the URL path. It will also trigger authorization for route guards.
  • Navigation Application  — This application is responsible for navigation logic and state, including the nav-bar component and navigation service.
  • User  Application —  This application is responsible for user logic and state including the user query and store logic, user info component, and user management page.
  • Feed  Application — This application is resp onsible for fetching and presenting the feed items. Each item contains the user logic and state, including the user query and store logic.

Application Structure

Each application will be built in a standard manner, out of the following layers:

  • Composition layer  — This layer holds a set of application pages with their corresponding routes.
  • Widgets layer  — This layer holds a set of domain-related components used to build the different pages found on the composition layer.
  • Business logic layer  — This layer holds a set of services and utilities responsible for the domain business logic.
  • Communication layer  — This layer holds a set of services that are used to communicate with the different service providers (Backend services for example).
  • Storage layer  — This layer holds the logic to persist data into the storage objects
    In memory — State, hooks e.g. Disk  ( local-storage), indexedDB, cookies, etc.

The Chaos

In this case, there is a clear relationship between the navigation application, feed application and the user application.

Thanks to module-federation, we can load micro frontend applications in run time without the need to build the entire dependency graph. This provides us the ability to build and deploy each application independently. However, we still need to keep in mind that all applications will be hosted side by side as a single monolith in the browser.

This introduces a whole new aspect of stability issues of frontend applications:

  • What happens when we are deploying a new version of our application?
  • How can we identify affected areas?
  • How can we guarantee there are no breaking changes hidden behind each deployment?
  • How can we prevent tight coupling between multiple applications that are hosted together?

Imagine a developer changed one of the widgets from the User application. This widget is consumed by both the Feed and the Navigation applications. Let’s say this change breaks the contract (component API — inputs/outputs, aka. props.) This will lead to a runtime error while loading the new version within the existing applications. 

And the result? Cascading failure of our frontend application after deployment of the new User application.

Layered Micro Frontends to the rescue!

First, let’s review the requirements of micro frontend applications:

  1. Each application should be built, tested and served as a standalone unit.
  2. A modification of a single application should be available to be used by any other application.
  3. Application widgets and services should be reusable and interchangeable.
  4. Encapsulation of application internal models and business logic — Modifications shouldn’t affect application consumers.
  5. Identify dependency graph per modification — will help us to trigger only the relevant tests suites and builds.

Notice that our application meets requirements 1-3, but fails 4 and 5, and we have a broken product!

Let’s review the different approaches to handle this chaos and meet all of the requirements.

Approach 1: The libraries approach

In order to increase the stability of the application, we need to prevent hidden breaking changes. With the libraries approach, we achieve stability while using the npm package version. As each build of our application is pinned with the library version it’s using, we can prevent the consumption of un-tested library versions that might contain breaking changes. Through using module federation, we can pin the library version as part of the application configuration with the required package version using semver (or other) standards.

This approach helps us to break our monolith into four layers:

Libraries 4 layers approach

  • Core Library — This layer contains domain agnostic libraries, those libraries provide us the building block for our feature libraries layer.
  • Feature libraries layer — This layer contains domain-specific business logic, storage logic, and widgets. Those widgets are developed based on the core libraries component kit and additional components that are part of the specific domain of responsibility.
  • Composition applications — This layer contains domain-specific routes and pages. Those pages are built based on widgets, services, and business logic developed as part of the “Feature libraries” layer.
  • Shell — The entry point of the application, usually acts as a container and a router to load each of the micro applications based on the path.
    The shell application might also trigger authorization logic.

Although the libraries approach covers the following requirements:

“1. Each application should be built, tested and served as a standalone unit.

3. Application widgets and services should be reusable and interchangeable.

4. Encapsulation of application internal models and business logic — Modifications shouldn’t affect application consumers.

5. Identify dependency graph per modification — will help us to trigger only the relevant tests suites and builds.”

This particular requirement is still not met:

“2. A modification of a single application should be available to be used by any other application.“

This is due to the fact, libraries modifications are not reflected automatically for each consumer, they will require rebuilding and redeploying of all downstream applications.

Setup

Structure:

– apps

 – user

 – feed

 – navigation

 – shell

– libs

 – users-lib

 – feed-lib

 – navigation-lib

 – auth

Webpack configuration:

plugins: [
 new ModuleFederationPlugin({
     name: "user",
     filename: "remoteEntry.js",
     exposes: {
         './bootstrap': './apps/user/bootstrap.module.ts',
     },
     shared: share({
       "@angular/core": { singleton: true, strictVersion: true, requiredVersion: '^12.0.0' },
       "@angular/common": { singleton: true, strictVersion: true, requiredVersion: '^12.0.0' },
       "@angular/common/http": { singleton: true, strictVersion: true, requiredVersion: '^12.0.0' },
       "@angular/router": { singleton: true, strictVersion: true, requiredVersion: '^12.0.0' },
       "@mfe/auth": { singleton: true, strictVersion: true, requiredVersion: '^1.0.0' },
       "@mfe/user": { singleton: true, strictVersion: true, requiredVersion: '^1.5.0' },        ...sharedMappings.getDescriptors()
     })
 }),
 sharedMappings.getPlugin()
],

Advantages of the Library Approach

  • We have created Shareable widgets, services, and pages (compositions) across applications.
  • We prevent breaking changes — using a pinned version of the consumed library during the build.

Disadvantages of the Library Approach

  • We may encounter data corruption : it’s possible due to a collision between multiple versions of the same library (which may override the state, local storage e.g.)
  • Unfortunately, our bundle size grows larger: different versions of lLibraries might be loaded more than once by different micro applications due to different requirements.
  • We have created deployment graph complexity: critical modifications require rebuilding and redeploying the entire dependency graph; in other words, in many situations, we may have to redeploy the entire application.

Approach 2: The anti-corruption layer

An anti-corruption layer is a set of Public-APIs exposed by an application for integration use. These public-APIs act as contracts in order to isolate the application’s internal models and business logic complexity and are used as exported modules, components, facades*, and adapters* classes. This layer can be unidirectional or bidirectional (fetch or ingest data.) 

Facade

The facade is a service that provides a simple interface to a complex application, encapsulating the complexity of initiating the application. The service may provide limited functionality, only the subset that is required for integrating into the micro frontend application.

For example, our feed component may provide multiple types of feeds, like user feeds, product feeds, news feeds, etc. However, since our application only requires the user feed, the facade would only expose an API for accessing that subset of functionality.

Adapter

The adapter is a service that is responsible for converting the interface and the data model of an object to another structure/interface which is accepted by the consumers.

For example, the feed component may utilize a data structure that is optimized for streaming multiple types of events that are in the feed. Since our application is only handling user feeds, the adapter service may transform the data structure from the underlying component to something more usable by this consumer.

The updated 4-layers approach

Anti-Corruption 4 layered approach

In this approach the feature layer connects libraries to applications, allowing us to serve those widgets and services seamlessly to the consumers. Adding an anti-corruption layer allows us to protect from breaking changes.

  • Shell applications layer  — The entry point of the application usually acts as a container and a router to load each of the micro applications based on the path. The shell application might also trigger authorization logic.
  • Composition applications layer  — This layer contains domain-specific routes and pages. Pages are built based on widgets, services, and business logic developed as part of the “Feature application” layer.
  • Feature application layer  — This layer contains domain-specific business logic, storage logic, and widgets. Widgets are developed based on the core libraries component kit and additional components that are part of the specific domain. The exposed logic and components are protected with an anti-corruption layer to prevent breaking changes.
  • Core libraries layer — This layer contains domain agnostic libraries, the building block for our feature libraries layer.

Setup

Structure:

As implemented here

|-- apps
| |-- user
| | |-- src
| | | |-- modules
| | | | |-- bootstrap
| | | | | |-- bootstrap.module.ts
| | |-- public-api.ts
| | |-- public-api.d.ts
| |-- feed
| | |-- src
| | | |-- modules
| | | | |-- bootstrap
| | | | | |-- bootstrap.module.ts
| | |-- public-api.ts
| | |-- public-api.d.ts
| |-- navigation
| | |-- src
| | | |-- modules
| | | | |-- bootstrap
| | | | | |-- bootstrap.module.ts
| | | |-- public-api.ts
| | | |-- public-api.d.ts
| |-- shell
|-- libs
| |-- auth

Webpack configuration:

As implemented here

plugins: [
 new ModuleFederationPlugin({
     name: "user",
     filename: "remoteEntry.js",
     exposes: {
         './public-api': './apps/user/public-api.ts',
     },
     shared: share({
       "@angular/core": { singleton: true, strictVersion: true, requiredVersion: '^12.0.0' },
       "@angular/common": { singleton: true, strictVersion: true, requiredVersion: '^12.0.0' },
       "@angular/common/http": { singleton: true, strictVersion: true, requiredVersion: '^12.0.0' },
       "@angular/router": { singleton: true, strictVersion: true, requiredVersion: '^12.0.0' },
       "@mfe/auth": { singleton: true, strictVersion: true, requiredVersion: '^1.0.0' },
       ...sharedMappings.getDescriptors()
     })
}),
 sharedMappings.getPlugin()
],
tsconfig:

In order to help us identify the dependency graph between the different applications, we will expose the definition files, those will be used on the build step of our applications, and will apply static code analysis against the facade and adapter interfaces.

As implemented here

{
...
"paths": {
 "@mfe/feed": ["apps/feed/public-api.d.ts"],
 "@mfe/navigation-bar": ["apps/navigation-bar/public-api.d.ts"],
 "@mfe/user": ["apps/user/public-api.d.ts"]
}
...
}

Advantages of the anti-corruption layer approach

  • We have created shareable widgets, services, and pages (compositions) across applications.
  • We got seamless propagation of an upgrade.
  • We placed an anti-corruption layer as the breaking-changes prevention layer. That also provides us encapsulation that will help with refactoring when is needed.

Disadvantages of the anti-corruption layer approach

  • Another layer to be maintained.
  • Education and learning curve.
  • Integration testing is required to verify unbreaking changes.

Micro Frontend at ZoomInfo 

Here at ZoomInfo, we are working on delivery in high-frequency with the ability to share widgets, business logic and complete applications between different modules and products across the apps. Those are possible in a much simpler way while adopting the layered micro frontend approach.

The first adoption of micro frontend was done with the Insent chat application where we were able to inject a full React-based application into an Angular-based platform and share data between the two applications natively. 

The second adoption was breaking out our monolithic frontend application (aka GrowUI.) The monolithic application is broken down into smaller applications, each one owned by a different team that maintains the widgets, business logic, exposed facade, test coverage and later on the release cycle of each application.

This approach helps us reduce time-to-market, increase developer productivity, and increase application stability.

Bonus

Demo project using the anti-corruption layer

Micro Frontend Demo application

The frontend application is built out of:

  • Shell Application
  • Feed Application (Blue)
  • Navigation Application (Purple)
  • User Application (Yellow)

Both Feed and Navigation consume components and functionality from the User application.

The shell application consumes the composition applications of Feed, Navigation, and User to serve the different pages.

Related Content