Using Module Federation with (Nx) Monorepos and Angular

The combination of Micro Frontends and monorepos can be quite tempting: Sharing libraries is easy while we can isolate domains using access restrictions and deploy them separately.

  1. The Microfrontend Revolution: Module Federation in Webpack 5
  2. The Microfrontend Revolution: Module Federation with Angular
  3. Dynamic Module Federation with Angular
  4. Building A Plugin-based Workflow Designer With Angular and Module Federation
  5. Getting Out of Version-Mismatch-Hell with Module Federation
  6. Using Module Federation with (Nx) Monorepos and Angular
  7. Pitfalls with Module Federation and Angular
  8. Multi-Framework and -Version Micro Frontends with Module Federation: Your 4 Steps Guide
  9. Module Federation with Angular’s Standalone Components
  10. What’s New in our Module Federation Plugin 14.3?

Using Module Federation with Nx Monorepos and Angular

While it sounds like a contradiction, the combination of Micro Frontends and Monorepos can actually be quite tempting: No version conflicts by design, easy code sharing and optimized bundles are some of the benefits you get. Also, you can still deploy Micro Frontends separately and isolate them from each other.

This article compares the consequences of using several repositories ("Micro Frontends by the book") and one sole monorepo. After that, it shows with an example, how to use Module Federation in an Nx monorepo.

If you want to have a look at the 📂 source code used here, you can check out this repository.

Big thanks to the awesome Tobias Koppers who gave me valuable insights into this topic and to the one and only Dmitriy Shekhovtsov who helped me using the Angular CLI/webpack 5 integration for this.

Important: This article is written for Angular 14.x and higher. Hence, you also need Nx 14.2.x or higher. To find out about the small differences for lower versions of Angular and for the migration from such a lower version, please have a look to our migration guide.

Multiple Repos vs. Monorepos

I know, the discussion about using multiple repos vs. monorepos can be quite emotional. Different people made different experiences with both approaches. However, I can tell you: I've seen both working in huge real-world projects. Still, both comes with different consequences, I'm going to discuss in the following two section.

At the end of the day, you need to evaluate these consequences against your very project situation and goals. This is what software architecture is about.

Multiple Repositories: Micro Frontends by the Book

A traditional approach uses a separate repository per Micro Frontend:

One Repository per Micro Frontend

This is also a quite usual for Micro Services and it provides the following advantages:

  • Micro Frontends -- and hence the individual business domains -- are isolated from each other. As there are no dependencies between them, different teams can evolve them separately.

  • Each team can concentrate on their Micro Frontend. They only need to focus on their very own repository.

  • Each team has the maximum amount of freedom in their repository. They can go with their very own architectural decisions, tech stacks, and build processes. Also, they decide by themselves when to update to newer versions.

  • Each Micro Frontend can be separately deployed.

As this best fits the original ideas of Micro Frontends, I call this approach "Micro Frontends by the book". However, there are also some disadvantages:

  • We need to version and distribute shared dependencies via npm. This can become quite an overhead, as after every change we need to assign a new version, publish it, and install it into the respective Micro Frontends.

  • As each team can use its own tech stack, we can end up with different frameworks and different versions of them. This might lead to version conflicts in the browser and to increased bundle sizes.

Of course, there are approaches to compensate for these drawbacks: For instance, we can automate the distribution of shared libraries to minimize the overhead. Also, we can avoid version conflicts by not sharing libraries between different Micro Frontends. Wrapping these Micro Frontends into web components further abstracts away the differences between frameworks.

While this prevents version conflicts, we still have increased bundle sizes. Also, we might need some workarounds here or there as Angular is not designed to work with another version of itself in the same browser window. Needless to say that there is no support by the Angular team for this idea.

If you find out that the advantages of this approach outweigh the disadvantages, you find a solution for mixing and matching different frameworks and versions here.

However, if you feel that the disadvantages weigh heavier, the next sections show an alternative.

Micro Frontends with Monorepos

Nearly all of the disadvantages outlined above can be prevented by putting all Micro Frontends into one sole monorepo:

Micro Frontends in a monorepo

Now, sharing libraries is easy and there is only one version of everything, hence we don't end up with version conflicts in the browser. We can also keep some advantages outlined above:

  • Micro Frontends can be isolated from each other by using linting rules. They prevent one Micro Frontend from depending upon others. Hence, teams can separately evolve their Micro Frontend.

  • Micro Frontends can still be separately deployed.

Now, the question is, where's the catch? Well, the thing is, now we are giving up some of the freedom: Teams need to agree on one version of dependencies like Angular and on a common update cycle for them. To put it in another way: We trade in some freedom to prevent version conflicts and increased bundle sizes.

One more time, you need to evaluate all these consequences for your very project. Hence, you need to know your architecture goals and prioritize them. As mentioned, I've seen both working in the wild in several projects. It's all about the different consequences.

Monorepo Example

After discussing the consequences of the approach outlined here, let's have a look at an implementation. The example used here is a Nx monorepo with a Micro Frontend shell (shell) and a Micro Frontend (mfe1, "micro frontend 1"). Both share a common library for authentication (auth-lib) that is also located in the monorepo. Also, mfe1 uses a library mfe1-domain-logic.

If you haven't used Nx before, just assume a CLI workspace with tons additional features. You can find more infos on Nx in our tutorial.

To visualize the monorepo's structure, one can use the Nx CLI to request a dependency graph:

nx graph

If you don't have installed this CLI, you can easily get it via npm (npm i -g nx). The displayed graph looks like this:

Dependency Graph generated by Nx

The auth-lib provides two components. One is logging-in users and the other one displays the current user. They are used by both, the shell and mfe1:

Schema

Also, the auth-lib stores the current user's name in a service.

As usual in Nx and Angular monorepos, libraries are referenced with path mappings defined in tsconfig.base.json (Nx) or tsconfig.json (Angular CLI):

 "paths": {
     "@demo/auth-lib": [
         "libs/auth-lib/src/index.ts"
     ]
 },

The shell and mfe1 (as well as further Micro Frontends we might add in the future) need to be deployable in separation and loaded at runtime.

However, we don't want to load the auth-lib twice or several times! Archiving this with an npm package is not that difficult. This is one of the most obvious and easy to use features of Module Federation. The next sections discuss how to do the same with libraries of a monorepo.

The Shared Lib

Before we delve into the solution, let's have a look at the auth-lib. It contains an AuthService that logs-in the user and remembers them using the property _userName:

 @Injectable({
   providedIn: 'root'
 })
 export class AuthService {

   // tslint:disable-next-line: variable-name
   private _userName: string = null;

   public get userName(): string {
     return this._userName;
   }

   constructor() { }

   login(userName: string, password: string): void {
     // Authentication for honest users
     // (c) Manfred Steyer
     this._userName = userName;
   }

   logout(): void {
     this._userName = null;
   }
 }

Besides this service, there is also a AuthComponent with the UI for logging-in the user and a UserComponent displaying the current user's name. Both components are registered with the library's NgModule:

 @NgModule({
   imports: [
     CommonModule,
     FormsModule
   ],
   declarations: [
     AuthComponent,
     UserComponent
   ],
   exports: [
     AuthComponent,
     UserComponent
   ],
 })
 export class AuthLibModule {}

As every library, it also has a barrel index.ts (sometimes also called public-api.ts) serving as the entry point. It exports everything consumers can use:

 export * from './lib/auth-lib.module';
 export * from './lib/auth.service';

 // Don't forget about your components!
 export * from './lib/auth/auth.component';
 export * from './lib/user/user.component';

Please note that index.ts is also exporting the two components although they are already registered with the also exported AuthLibModule. In the scenario discussed here, this is vital in order to make sure it's detected and compiled by Ivy.

Let's assume the shell is using the AuthComponent and mfe1 uses the UserComponent. As our goal is to only load the auth-lib once, this also allows for sharing information on the logged-in user.

The Module Federation Configuration

As in the previous article, we are using the @angular-architects/module-federation plugin to enable Module Federation for the shell and mfe1. For this, just run the following commands:

npm i @angular-architects/module-federation -D

npm g @angular-architects/module-federation:init --project shell --port 4200 --type host

npm g @angular-architects/module-federation:init --project mfe1 --port 4201 --type remote

Meanwhile, Nx also ships with its own support for Module Federation. Beyond the covers, it handles Module Federation in a very similar way as the plugin used here.

This generates a webpack config for Module Federation. Since version 14.3, the withModuleFederationPlugin provides a property sharedMappings. Here, we can register the monorepo internal libs we want to share at runtime:

 // apps/shell/webpack.config.js

const { shareAll, withModuleFederationPlugin } = require('@angular-architects/module-federation/webpack');

module.exports = withModuleFederationPlugin({

     remotes: {
         'mfe1': "http://localhost:4201/remoteEntry.js" 
     },

     shared: shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }),

     sharedMappings: ['@demo/auth-lib'],

 });

As sharing is always an opt-in in Module Federation, we also need the same setting in the Micro Frontend's configuration:

 // apps/mfe1/webpack.config.js

 const { shareAll, withModuleFederationPlugin } = require('@angular-architects/module-federation/webpack');

 module.exports = withModuleFederationPlugin({

     name: "mfe1",

     exposes: {
       './Module': './apps/mfe1/src/app/flights/flights.module.ts',
     }, 

     shared: shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }),

     sharedMappings: ['@demo/auth-lib'],

 });

Since version 14.3, the Module Federation plugin shares all libraries in the monorepo by default. To get this default behavior, just skip the sharedMappings property. If you use it, only the mentioned libs are shared.

Trying it out

To try this out, just start the two applications. As we use Nx, this can be done with the following command:

nx run-many --target serve --all

The switch --all starts all applications in the monorepo. Instead, you can also go with the switch --projects to just start a subset of them:

nx run-many --target serve --projects shell,mfe1

--project takes a comma-separated list of project names. Spaces are not allowed.

After starting the applications, log-in in the shell and make it to load mfe1. If you see the logged-in user name in mfe1, you have the proof that auth-lib is only loaded once and shared across the applications.

Isolating Micro Frontends

One important goal of a Micro Frontend architecture is to isolate Micro Frontends from each other. Only if they don't depend on each other, they can be evolved by autarkic teams. For this, Nx provides linting rules. Once in place, they give us errors when we directly reference code belonging to another Micro Frontend and hence another business domain.

In the following example, the shell tries to access a library belonging to mfe1:

Linting prevents coupling between Micro Frontends

To make these error messages appear in your IDE, you need eslint support. For Visual Studio Code, this can be installed via an extension.

Besides checking against linting rules in your IDE, one can also call the linter on the command line:

Linting on the command line

The good message: If it works on the command line, it can be automated. For instance, your build process could execute this command and prevent merging a feature into the main branch if these linting rules fail: No broken windows anymore.

For configuring these linting rules, we need to add tags to each app and lib in our monorepo. For this, you can adjust the project.json in the app's or lib's folder. For instance, the project.json for the shell can be found here: apps/shell/project.json. At the end, you find a property tag, I've set to scope:shell:

 {
     [...]
     "tags": ["scope:shell"]
 }

The value for the tags are just strings. Hence, you can set any possible value. I've repeated this for mfe1 (scope:mfe1) and the auth-lib (scope:auth-lib).

Once the tags are in place, you can use them to define constraints in your global eslint configuration (.eslintrc.json):

 "@nrwl/nx/enforce-module-boundaries": [
   "error",
   {
     "enforceBuildableLibDependency": true,
     "allow": [],
     "depConstraints": [
       {
         "sourceTag": "scope:shell",
         "onlyDependOnLibsWithTags": ["scope:shell", "scope:shared"]
       },
       {
         "sourceTag": "scope:mfe1",
         "onlyDependOnLibsWithTags": ["scope:mfe1", "scope:shared"]
       },
       {
         "sourceTag": "scope:shared",
         "onlyDependOnLibsWithTags": ["scope:shared"]
       }
     ]
   }
 ]

After changing global configuration files like the .eslintrc.json, it's a good idea to restart your IDE (or at least affected services of your IDE). This makes sure the changes are respected.

More on these ideas and their implementation with Nx can be found in my blog series on Strategic Design (Domain-driven Design) and Angular.

Incremental Builds

To build all apps, you can use Nx' run-many command:

nx run-many --target build --all

However, this does not mean that Nx always rebuilds all the Micro Frontends as well as the shell. Instead, it only rebuilds the changed apps. For instance, in the following case mfe1 was not changed. Hence, only the shell is rebuilt:

Nx only rebuilds changed apps

Using the build cache to only recompile changed apps can dramatically speed up your build times.

This also works for testing, e2e-tests, and linting out of the box. If an application (or library) hasn't been changed, it's neither retested nor relinted. Instead, the result is taken from the Nx build cache.

By default, the build cache is located in node_modules/.cache/nx. However, there are several options for configuring how and where to cache.

Deploying

As normally, libraries don't have versions in a monorepo, we should always redeploy all the changed Micro Frontends together. Fortunately, Nx helps with finding out which applications/ Micro Frontends have been changed or affected by a change:

nx print-affected --type app --select projects

You might also want to detect the changed applications as part of your build process.

Redeploying all applications that have been changed or affected by a (lib) change is vital, if you share libraries at runtime. If you have a release branch, it's enough to just redeploy all apps that have been changed in this branch.

If you want to have a graphical representation of the changed parts of your monorepo, you can request a dependency graph with the following command:

nx affected:graph

Assuming we changed the domain-logic lib used by mfe1, the result would look as follows:

Dependency graph shows affected projects

By default, the shown commands compare your current working directory with the main branch. However, you can use these commands with the switches --base and --head.

nx print-affected --type app --select projects --base branch-or-commit-a --head branch-or-commit-b

These switches take a commit hash or the name of a branch. In the latter case, the last commit of the mentioned branch is used for the comparison.

What's next? More on Architecture!

So far, we've seen that Module Federation is a straightforward solution for creating Micro Frontends on top of Angular. However, when dealing with it, several additional questions come in mind:

  • According to which criteria can we sub-divide a huge application into micro frontends?
  • Which access restrictions make sense?
  • Which proven patterns should we use?
  • How can we avoid pitfalls when working with Module Federation?
  • Which advanced scenarios are possible?

Our free eBook (about 100 pages) covers all these questions and more:

free ebook

Feel free to download it here now!

Conclusion

By using monorepos for Micro Frontends you trade in some freedom for preventing issues. You can still deploy Micro Frontends separately and thanks to linting rules provided by Nx Micro Frontends can be isolated from each other.

However, you need to agree on common versions for the frameworks and libraries used. Therefore, you don't end up with version conflicts at runtime. This also prevents increased bundles.

Both works, however, both has different consequences. It's on you to evaluate these consequences for your very project.