Microfrontends, each usually placed in its own repository, can find a home together in a monorepo. Monorepos simplify tasks that arise around microfrontends, but have a few intentional limitations. In order for them to scale, specialized processes and tools are necessary.
The main goal of microfrontends is isolation: Individual small applications that form a larger system together are to be implemented independently of each other. This means that a small team can take care of each application. Since these teams are self-sufficient, they do not need to coordinate with each other and can deliver business value as quickly as possible. This is why it is also common to place microfrontends in their own repository [1]. While this brings isolation, it also creates extra work managing and sharing libraries. Monorepos, on the other hand, which accommodate several microfrontends, make these tasks easier.
In this article, I would like to highlight the consequences of both approaches. As an example [2], I will use an Angular solution based on Module Federation and an Nx monorepo.
One repository per microfrontend
The use of multiple repositories ensures maximum isolation. The repository boundaries prevent dependencies between the individual microfrontends (Fig. 1).
Since each team has its own repository, they can act autonomously: Each team decides for itself which frameworks, libraries, and versions to use. The timing of updates is also up to the teams. In principle, coordination with other teams is not necessary. This makes the process more agile. Internal libraries shared by teams are versioned and made available via an npm registry. To keep our efforts for this as low as possible, a high degree of automation is necessary. However, this also means that necessary knowledge must be available for the team.
It may happen that multiple versions of the same internal library need to be maintained. Finally, each team can decide when (and if) to update. For example, a team that has decided to stick with Angular 10 will also need versions of the internal libraries compiled for Angular 10. If the individual microfrontends are presented to the user as an integrated solution, it may also be necessary to load multiple versions of the same frameworks and libraries. For example, both Angular 10 and Angular 11 could end up in the browser. Of course, this has a negative impact on loading times. This highlights a trade-off between isolation and optimized load times: either the individual application bundles are isolated from each other and bring their own dependencies, or the individual microfrontends access shared dependencies. In the latter case, teams must agree on the shared versions at the expense of an isolated approach.
iJS Newsletter
Keep up with JavaScript’s latest news!
One monorepo for all microfrontends
An alternative organizational approach used by companies such as Google, Facebook, and Microsoft is the monorepo. This is a repository that houses several related projects (Fig. 2).
Monorepos simplify sharing source code. Instead of publishing a new version via a registry, changes are simply transferred to a corresponding branch. Additionally, only the latest version in the repository needs to be maintained. Some monorepo implementations also ensure that each dependency is only available in a single version for all projects. The above-mentioned case of multiple Angular versions colliding with each other cannot happen in the first place.
Processes and Tools for Scalable Monorepos
However, the monorepo approach only scales if both appropriate processes and tools are used. The processes regulate how library breaking changes are to be handled. It is conceivable to establish a deprecation policy that provides backward compatibility for a period of time or to publish new library versions in release branches. These allow product teams to test whether the eventual transition to the new library version will work. Clearly, these processes limit the authority of individual teams. They prevent the coexistence of different library versions and ensure that version conflicts are avoided, or at least resolved early on.
In addition to processes, teams also need appropriate tools for the deployment of scalable monorepos. Isolation between individual domains and between microfrontends can be ensured via linting. A popular tool that automates these ideas is Nx [3]. For example, Figure 3 shows a linting rule in action offered by Nx that restricts access between individual subprojects.
To avoid having to constantly build and test the entire monorepo, we need the option to perform these tasks only for the changed parts of the program. Nx also provides solutions for incremental builds and tests.
GAIN INSIGHTS INTO THE DO'S & DON'TS
Angular Tips & Tricks
Microfrontends with Module Federation and an Nx Monorepo
To illustrate the ideas we discussed about microfrontends and monorepos in the last two sections, I will use a simple example based on Angular and Nx. A shell handles the integration of the front-ends. This is an SPA that loads the microfrontends on demand. For this task, the shell uses the Module Federation provided by webpack 5 [4]. The CLI extension @angular-architects/module-federation [5] takes care of bridging the gap between Angular and the Angular CLI on the one hand, and Module Federation on the other. For further information on how to get started with these tools, follow the links at the end of the article.
The structure of the monorepos reflects the dependency graph generated with Nx in Figure 4.
In addition to a shell, the monorepo includes a first microfrontend: mfe1. Both applications are compiled and deployed separately. The mfe1 project uses two specialized libraries: mfe1-feature-search and mfe1-feature-book. However, it does not share these with other applications, but uses them only for code structuring. Therefore Nx can include these two libraries in the compilation of mfe1.
The technical library shared-auth-lib is different. The shell and mfe1 share this library. It is a good idea to compile it separately and load it only once at runtime. This not only improves startup performance, which is sometimes important for web clients, but also allows applications to share the state of this library. This is simply the name of the logged-in user (Fig. 5).
Sharing dependencies with Module Federation
The configuration used by Module Federation allows you to specify node packages that a microfrontend should share. For example, the snippet in Listing 1 specifies that the @ angular/core, @angular/common, and @angular/router libraries are to be shared.
new ModuleFederationPlugin({ […] shared: { "@angular/core": { singleton: true, strictVersion: true }, "@angular/common": { singleton: true, strictVersion: true }, "@angular/router": { singleton: true, strictVersion: true } } }),
Sharing means that the build process creates separate bundles for these libraries. At program startup, the shell and microfrontends negotiate whose version to use. By default, Module Federation chooses the highest compatible version. For example, if versions 10.0 and 10.1 are available, it will choose 10.1. If no highest compatible version exists, there is a conflict. This is the case if different major versions are available which, according to Semantic Versioning, do not have to be compatible with each other, for example, 10.0 and 11.0. The configuration can be used to specify how Module Federation should resolve such conflicts. In this example, the properties singleton: true and strictVersion: true specify that a runtime error should be generated. Details can be found in [6].
However, the challenge with Angular CLI-based monorepos now is that the shared libraries are not npm packages. They are simply folders that are given a name similar to that of an npm package via path mappings in the TypeScript configuration. Fortunately, the makers of Module Federation have taken this into account: Instead of referencing an npm package, the configuration can directly accept the required metadata (Listing 2).
new ModuleFederationPlugin({ […] shared: { […] "@nx-mf-demo/shared/auth-lib": { import: path.resolve(__dirname, "../../libs/auth-lib/src/index.ts"), version: '1.0.0', requiredVersion: '^1.0.0' }, } }),
The key of the configuration object points to the name of the path mapping. This is used by the applications to import the shared library:
import { AuthLibModule } from '@nx-mf-demo/shared/auth-lib';
The import property points to the library’s entry point. This is usually its public API that exports selected elements. The currently available version of this library is found in version. The version(s) that the configured application accepts must be stored in requiredVersion.
These two versions are used by Module Federation during the negotiation at program startup. In the considered case, the configured application offers version 1.0.0, while it is also satisfied with a higher version (>1.0.0), provided that another microfrontend offers such a version. If we assume that only the highest version always exists in a monorepo, and that deployment always includes all changed program parts, we can also disable this negotiation. To do this, we only need to set requiredVersion to false (Listing 3).
new ModuleFederationPlugin({ [...] shared: { [...] "@nx-mf-demo/shared/auth-lib": { import: path.resolve(__dirname, "../../libs/auth-lib/src/index.ts"), requiredVersion: false }, } }),
In the case of Angular, this is complicated by the fact that the code generated by the Angular compiler also needs to access shared libraries. To enable this, it requires a few details in the configuration. So that we don’t have to bother with this, the aforementioned CLI extension @angular-architects/module-federation provides a helper class SharedMappings (Listing 4).
[...] const sharedMappings = new mf.SharedMappings(); sharedMappings.register( path.join(__dirname, '../../tsconfig.base.json'), ['@nx-mf-demo/shared/auth-lib']); module.exports = { […] plugins: [ new ModuleFederationPlugin({ [...] shared: { "@angular/core": { [...] }, "@angular/common": { [...] }, "@angular/router": { [...] }, sharedMappings.getDescriptors() } }), sharedMappings.getPlugin(), ], };
Using the register method, SharedMappings takes the names of the mapped paths that act as monorepo-internal libraries. With this, getDescriptors generates the settings discussed in Listing 2 and Listing 3. The getPlugin method returns a configured webpack plug-in required for the code generated by the Angular compiler. Since webpack-5 integration is currently still an experimental feature of the Angular CLI, there are one or two pitfalls here. An overview can be found at the end of [5]. However, with CLI 12, the Angular team aims to ship a stable webpack-5 integration in May 2021. Then these pitfalls should be a thing of the past.
Deployment
Since there is always only one version of all libraries in a monorepo, it makes sense to deploy all applications (microfrontends, shell) affected by program changes that have been made. To find out which applications are affected by the changes, Nx provides the npm script affected:apps (Fig. 6).
By default, this command compares the current source code with that of the main branch. Its name can be stored in nx.json. However, by specifying appropriate parameters, we can also compare any two branches or even any two commits. In addition, Nx performs a static source code analysis to identify all applications depending on the changed files.
Since the npm script affected:apps outputs the affected applications to the console, it can be integrated wonderfully into the CI/CD process. Thus, all affected applications can be deployed automatically. To get the same information in a graphical way, the npm script affected:dep-graph is useful. It leads to a dependency graph that presents all program parts affected by changes with red frames (Fig. 7).
Additionally, we can use the affected:build script to rebuild only the affected applications. The affected:test script only runs the unit tests for these applications; affected:e2e does the same with their end-to-end tests.
Isolation
As mentioned at the beginning, the main goal of microfrontends is to isolate applications so that individual teams can (further) develop them as autonomously as possible. The use of one repository per application helps. Monorepos, on the other hand, require linting for this. Nx supports, among other things, the popular linting tool ESLint and, based on this, offers a linting rule that can restrict access between applications and libraries.
To use this option, we first assign one or more categories (tags) to each application (microfrontend) and library within the generated nx.json (Listing 5).
"shell": { "tags": ["scope:shell"] }, "mfe1": { "tags": ["scope:mfe1"] }, "shared-auth-lib": { "tags": ["scope:shared"] }, "mfe1-feature-search": { "tags": ["scope:mfe1"] }, "mfe1-feature-book": { "tags": ["scope:mfe1"] }
We can freely assign individual categories. The example shown here uses these categories to define whether the respective application belongs to the shell (scope:shell) or to the microfrontend (scope:mfe1) or is shared by both (scope:shared).
Based on these categories, the linting rule @nrwl/nx/enforce-module-boundaries can be configured in the .eslintrc.json file (Listing 6).
"rules": { "@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"] } ] } ] }
The configuration shown here ensures that the shell is not allowed to access the microfrontend directly and vice versa. However, both are allowed to use the shared auth-lib. A violation of these rules is punished with a linting error. It is presented during typing by the IDE (Fig. 3), if it supports ESLint. Additionally, linting can be triggered on the command line with ng lint (Fig. 8).
Linting can also be executed as part of the CI/CD process or as part of check-in rules, enforcing isolation between microfrontends.
Summary
Using separate repositories provides maximum flexibility. However, this comes with a price: we have to version split packages, deploy them via a registry, and maintain multiple versions if necessary. Since each team decides for itself which packages and versions to use, it is sometimes necessary to load multiple frameworks or even multiple versions of the same framework into the browser. In monorepo deployment, teams deliberately limit themselves a little: they always use the latest versions of internal libraries and only have to maintain these. They also agree on which versions of external libraries to use, such as Angular or React. This also reduces the size of the bundles to be loaded.
For the monorepo approach to work, processes need to be coordinated with it. These determine the extent to which new versions must be downward compatible. In addition, Monorepo projects only scale if appropriate tools are used. For example, the Angular CLI extension Nx under consideration allows direct access between individual microfrontends to be restricted via linting. It also supports incremental builds and tests and identifies microfrontends that have changed since the last deployment. Even though libraries in Nx-Monorepos are not npm packages, Module Federation can share them at runtime. To do this, metadata that it otherwise takes from the packages must be specified manually. The @angular-architects/module-federation plug-in automates this task.
Links & Literature
[1] https://martinfowler.com/articles/micro-frontends.html
[2] https://github.com/manfredsteyer/nx-and-module-federation
[5] https://www.npmjs.com/package/@angular-architects/module-federation