25 Oct 2021
5 min

The Micro-Frontend Chaos (and how to solve it)

Why do we need Micro-Frontend?

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

As the frontend become larger, while holding multiple domains of responsibilities (for example — navigation, user, authorization, e.g.), the frontend application is developed, deployed and hosted as a single application.

The separation is usually already found on the codebase while using a directory per module, library, and sometimes even application.

The micro-frontend allows us to use those different applications not just on the code base, but also in development, deployment and serve, in order to provide:

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

E2E Domains hold by different teams

Overview

During this article we will review the frontend application that is built out of the shell and 3 applications that represent a domain:

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

Each application built out 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 e.g.

The Chaos

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

Thanks to module-federation, we can load Micro-frontends applications during run time without the need to build the entire dependency graph.
This provides us the ability to build and deploy each application independently. But, even though, we need to keep in mind all of those applications are going to be hosted side by side as a single monolith on the user 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 (User in our scenario)?
  • 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?

In our example, imagine a developer changed one of the widgets from the User application, this widget is consumed by both the Feed and the Navigation applications. Now, let's imagine the change the developer done is breaking 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.

Tackling the problem

First, let’s review the requirements we have from the 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.

Following those items, lets’ review the approach from the previous section:
The approach covers bullets 1 to 3 from the requirements list. But, it still fails for both bullets 4 and 5 which promise us the stability of our product.

Let’s review the different approaches to handle this chaos:

The libraries approach

In order to increase the stability of the application, we need to prevent hidden breaking changes.
With the libraries approach, this can achieve easily while using the npm package version. As each build of our applications is sealing the library version it’s using we can prevent consumption of library versions that might contain breaking changes.

Using module federation, we can set the shared libraries, as part of this configuration we can set the satisfied package version using the npm package versioning convention.

This approach helps us to break our monolith into 4 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 this approach covers bullets 1,3–5, Bullet 2 is the price needed to be paid. As a modification of a library is not reflected automatically to each consumer application but requires rebuilding and redeploying of these 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

  • Shareable widgets, services, and pages (compositions) across applications.
  • Breaking changes prevention — Using a sealed version of the consumed library during the build.

Disadvantages

  • Data corruption — Possible due to collision between multiple versions of the same library (override the state, local storage e.g.).
  • Bundle size increase — Libraries might be loaded more than once due to different versions.
  • Deployment graph complexity — critical modifications require rebuilding and redeploying the entire dependency graph.

The anti-corruption layer approach

What is the anti-corruption layer?

An anti-corruption layer is a set of Public-APIs exposed by an application for integration use, those Public-APIs are acting as contracts in order to isolate the application internal models and business logic complexity.
and are used as exported modules, components, façade*, and adapters* classes.

This layer can be uni-directional or bi-directional (fetch or ingest data).

Façade

A service that provides a simple interface to a complex application, encapsulates the complexity of initiating the application.
A façade might provide limited functionality, those are the required sub-set for integrating with the micro-frontend application

Adapter

A service that is responsible for covert the interface and the data model of an object to another structure/interface which is accepted by the consumers.

The updated 4-layers approach

Anti-Corruption 4 layered approach

The only modification is casting the Feature layer from libraries to applications, this allows us to serve those widgets and services seamlessly to the consumers. Having said that we will still need to protect from breaking changes, here is where the anti-corruption layer is taking place

  • Core Library — This layer contains domain agnostic libraries, those libraries provide us the building block for our feature libraries layer.

  • Feature application 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.
    The exposed logic and components are protected with an anti-corruption layer to prevent breaking changes.

  • 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 application” 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.

Setup

Structure:

- 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:

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

{
...
"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

  • Shareable widgets, services, and pages (compositions) across applications
  • Seamless propagation of an upgrade
  • Breaking changes prevention using Anti-Corruption layer.
  • Refactor is becoming more simple thanks to encapsulation.

Disadvantages

  • Another layer to be maintain
  • Education and learning curve
  • Integration testing is required to promise unbreaking changes

Bonus

React demo project using the anti-corruption layer (bit different than the picture and description below)

Angular 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.

Frequently asked questions

What is the difference between the libraries approach and the anti-corruption layer approach for micro-frontends in Angular?

The libraries approach shares versioned npm packages, so each consumer build seals the library version and blocks unexpected breaking changes, but every change requires rebuilding and redeploying dependent applications. The anti-corruption layer instead exposes a public API from each application through a public-api.ts entry point, so upgrades propagate without redeploying consumers, though it needs integration tests and an extra layer to maintain.

How do I prevent breaking changes when sharing components between Angular micro-frontends?

Expose only a deliberate public API from each micro-frontend, such as a public-api.ts entry point containing facades and adapters, and keep internal models hidden behind it. Consumers then check against the exposed public-api.d.ts definitions during their build, so a changed component input or output surfaces at compile time instead of as a runtime error. Adding integration tests against the exposed contracts covers behavior the types cannot verify.

What is an anti-corruption layer in Angular micro-frontends?

An anti-corruption layer is a set of public APIs an application exposes for integration, acting as a contract that isolates its internal models and business logic from consumers. It is made of exported modules, components, facades, and adapters, and can be uni-directional (fetching data) or bi-directional (fetching and ingesting data). In an Angular setup, each feature application exposes its layer through a public-api.ts file loaded via Module Federation.

How do I share Angular dependencies between micro-frontends with Module Federation?

Configure each application's ModuleFederationPlugin with shared Angular packages marked singleton: true and strictVersion: true, plus a requiredVersion such as ^12.0.0. The singleton flag ensures only one copy of @angular/core runs in the browser, while strictVersion treats a version mismatch as an error rather than a warning. Shared domain packages like @mfe/auth can be declared the same way.

What is the difference between a facade and an adapter in a micro-frontend public API?

A facade is a simple service that hides the complexity of initializing or operating an application, exposing only the subset of functionality consumers need. An adapter converts the interface and data model of an object into a structure consumers accept. In an anti-corruption layer, facades keep the integration surface small, while adapters translate models so internal changes don't leak into other micro-frontends.

Why does changing a component's inputs or outputs in one micro-frontend break other applications at runtime?

Changing a component's inputs or outputs in one micro-frontend breaks consumers because they bind to that component's contract, such as @Input() and @Output() props, and the new version no longer matches what they expect. Since all micro-frontends run side by side in one browser, the mismatch surfaces as a runtime error while the remote loads, which can cascade across the whole shell. Protecting exposed components with an anti-corruption layer prevents this.

Share this post

Sign up for our newsletter

Stay up-to-date with the trends and be a part of a thriving community.