Angular 12 recently launched with the added enhancements of Webpack 5 and opening the door to using module federation. If you are looking for a great deep-dive into module federation and micro-frontends, I suggest reading: https://www.angulararchitects.io/aktuelles/the-microfrontend-revolution-module-federation-in-webpack-5/.
Micro frontends
Micro frontends and more importantly module federation, allows developers the flexibility of remotely requesting a module on the network and bootstrapping that module into their application. Similar to lazy-loading, remotely loading modules can greatly reduce the bundle size of your application and the network cost to loading modules that end up unused by your users.
There's other benefits to micro-frontends, including:
- A/B serving features
- Incremental updates
- Independent versioning of features
- Dynamic feature resolutions
Getting started
The Angular Architects package @angular-architects/module-federation
creates a simple API to request modules and pull them into your application.
Assuming an NX mono-repo set-up:
To add module federation to your workspace, run:
nx add @angular-architects/module-federation@next
This will install the necessary dependency, with the schematics needed to add remote apps to be consumed by module federation.
Let's assume you have the following mono-repo:
apps/
shell/
remote/
Shell is your consuming application. It is the highest container, responsible for what pieces are pulled in and the composition of features.
Remote is the feature set, isolated and decoupled to be pulled in on-demand, by the shell.
To make these apps compatible with module federation, you will need to run the schematic on their projects:
nx add @angular-architects/module-federation --project shell --port 5000
nx add @angular-architects/module-federation --project remote --port 6000
You can configure the port to be whatever you desire. This only matters for local development.
This schematic will:
- Generate a
webpack.config.js
andwebpack.config.prod.js
with a boilerplate for module federation - Update
angular.json
for the project definition, to reference theextraWebpackConfig
and update the project's port to the value specified - Split the bootstrap logic of your app from
main.ts
tobootstrap.ts
and reference the function inmain.ts
.
Module Federation Plugin
Inside your webpack.config.js
you will want to get accommodated with the config for module federation.
module.exports = {
output: {
uniqueName: 'remote',
publicPath: 'auto',
},
optimization: {
runtimeChunk: false,
},
resolve: {
alias: {
...sharedMappings.getAliases(),
},
},
plugins: [
new ModuleFederationPlugin({
name: 'remote',
filename: 'remoteEntry.js',
exposes: {
'./Module':
'./apps/remote/src/app/app.module.ts',
},
shared: {
'@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',
},
...sharedMappings.getDescriptors(),
},
}),
sharedMappings.getPlugin(),
],
};
-
name
should align with youroutput.uniqueName
and match your shell app's webpack config for the remotes section. -
fileName
is the name of the generated file's entry point to your remote module. This file name will not be renamed in the build process and is the asset you will be referencing in your shell to request the module. -
exposes
is the named paths to modules, components, etc. that you want to make accessible to the shell to pull in. I'll explain this further below. -
shared
the shared dependencies (and rules) between your remote and shell app. This allows tight control for your remote to not re-declare modules/services that you expect to be singleton, or prevent mismatched versions of Angular or other libraries existing in the eco-system. By assigningstrictVersion
totrue
, the build will quick fail if an issue occurs. Removing this option will potentially pass the build, but display warnings in the dev console.
You can now locally run your shell and remote with:
nx serve shell -o
nx serve remote -o
-o
will automatically launch the apps in your default browser
Exposes (continued)
While the example schematic will generate the exposes
section with the AppModule
and AppComponent
I would strongly advise against this.
When serving the remote and shell to develop locally, the sites will be deployed to:
- localhost:5000
- localhost:6000
When you make changes to the remote
app folder's contents, only localhost:6000
will live-reload.
This means for local development, consuming the remote into the shell app is not sustainable for development against remote-specific functionality.
So what do I propose?
The AppModule
of your remote app should be your "demo" or self-deployed landscape. You will import modules and providers to establish a foundation to locally test your remote app in isolation. The AppModule
should have a separate module of the cohesive functionality you are wanting to expose, i.e: LoginModule
.
With this approach, exposing and pulling in AppModule
has the potential to pulling in duplicate root providers; as well as pulling duplicate assets and styles.
Instead with:
exposes: {
'./Module':
'./apps/remote/src/app/login/login.module.ts',
},
./Module
is nomenclature you can define as you please. I would recommend being more specific in a diverse system.
The shell app still can access the shared functionality to pull in, but doesn't pull in more than it needs to.
I can locally develop on localhost:6000
, having an accurate test bed for my application and live-dev against the changes with ease.
Now that the foundation of module federation have been set, let's jump into dynamically swapping modules at runtime.
Dynamic Runtime modules
All of the top resources available for module federation show statically referencing the modules in your shell app's route definition.
import { loadRemoteModule } from '@angular-architects/module-federation';
[...]
const routes: Routes = [
[...]
{
path: 'flights',
loadChildren: () =>
loadRemoteModule({
remoteEntry: 'http://localhost:3000/remoteEntry.js',
remoteName: 'mfe1',
exposedModule: './Module'
})
.then(m => m.FlightsModule)
},
[...]
];
This serves a purpose when your application wants to independently build and manage known features. This doesn't however allow you conditionally serve features or create an application that does not have context of what features exist at build time.
Dynamic module federation
Dynamic module federation attempts to resolve this by allowing you independently request modules before bootstrapping Angular:
import { loadRemoteEntry } from '@angular-architects/module-federation';
Promise.all([
loadRemoteEntry('http://localhost:3000/remoteEntry.js', 'mfe1')
])
.catch(err => console.error('Error loading remote entries', err))
.then(() => import('./bootstrap'))
.catch(err => console.error(err));
Better... but still has a few drawbacks:
- What if my remote module is routable? Will it recognize the route when I navigate directly to it?
- How does this impact lazy loading?
- Remote entries are still hard-coded
Dynamic runtime module federation
We need the ability to have a decoupled shell, that can dynamically request federated modules at runtime.
A real use case?
On our team, we want to dynamically serve separate authentication experiences for customers. Some customers use our platform's stock username/password authentication. Others have their own corporate SSO. All of them have strict branding standards that aren't compatible with each other.
We do however, want all customers to share the primary functionality of our platform - content management and learning delivery. Once they login to the application, they only need branding for their corporate logo and primary brand color; they can use all the existing interfaces.
Less rigid example?
Feature toggles in an application. Some customers have "X" others have "Y". You want to serve one app that can respond to "X" and "Y".
Getting started
Authentication deals with routing and we need to allow our users to navigate to /authentication/login
and get served the correct federated module for their company.
We will be using an injection token to store our route definitions as they relate to module federation.
export const PLATFORM_ROUTES = new InjectionToken<Routes>('Platform routes for module federation');
If you used the the schematic discussed above, you should have a bootstrap.ts
file. Prior to bootstrapping Angular, we need to request the registry of the modules that should exist for this user. This can be any network call, for this demo we will use a local JSON asset called platform-config.json
Platform config is going to describe all the modules, the location of the modules, the module name to bootstrap and the route to register in the shell app for the remote module.
{
"authentication": {
"path": "authentication",
"remoteEntry": "http://localhost:5001/remoteEntry.js",
"remoteName": "coreAuthentication",
"exposedModule": "./LoginModule",
"exposedModuleName": "LoginModule"
}
}
-
path
is the Angular route namespace to load the remote module under. -
remoteEntry
is the served location of your remote module. This would be replaced with the served location (CDN, CloudFoundry, S3 asset, etc.) in a built environment. This currently references where we will be serving our Angular apps for local development. -
exposedModule
is the key in your remote app'swebpack.config.js
for the exposed module (your nomenclature) -
exposedModuleName
is the name of the Angular module that was exposed, this is leveraged for lazy loading.
In bootstrap.ts
we will consume this asset and build the injection token value:
import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { Routes } from '@angular/router';
import { loadRemoteModule } from '@angular-architects/module-federation';
import { AppModule } from './app/app.module';
import { PLATFORM_ROUTES } from './app/platform-routes';
import { environment } from './environments/environment';
if (environment.production) {
enableProdMode();
}
fetch('/assets/platform-config.json').then(async (res) => {
const config = await res.json();
const platformRoutes: Routes = [];
for (const [key, value] of Object.entries<any>(config)) {
platformRoutes.push({
path: value.path,
loadChildren: () =>
loadRemoteModule({
remoteEntry: value.remoteEntry,
remoteName: value.remoteName,
exposedModule: value.exposedModule,
}).then((m) => m[value.exposedModuleName]),
});
}
platformBrowserDynamic([
{
provide: PLATFORM_ROUTES,
useValue: platformRoutes,
multi: true,
},
])
.bootstrapModule(AppModule)
.catch((err) => console.error(err));
});
By passing the providers to platformBrowserDynamic
, we are setting a static provider value prior to bootstrap, that can be used on bootstrap.
In the module responsible for your shell app's router module declaration (typically app-routing.module.ts
), update as follows:
import { NgModule } from '@angular/core';
import { RouterModule, ROUTES, Routes } from '@angular/router';
import { PLATFORM_ROUTES } from './platform-routes';
@NgModule({
imports: [
RouterModule.forRoot(
[
/* Declare root routes in the factory below */
],
{ initialNavigation: 'enabled' }
),
{
ngModule: RouterModule,
providers: [
{
provide: ROUTES,
useFactory: (
staticRoutes: Routes = [],
dynamicRoutes: Routes = []
) => {
let rootRoutes: Routes = [];
if (Array.isArray(staticRoutes)) {
rootRoutes = [...staticRoutes];
}
if (Array.isArray(dynamicRoutes)) {
rootRoutes = [...rootRoutes, ...dynamicRoutes];
}
rootRoutes.push({
path: '**',
redirectTo: '/authentication/login',
});
return rootRoutes;
},
deps: [ROUTES, PLATFORM_ROUTES],
},
],
},
],
exports: [RouterModule],
})
export class AppRoutingModule {}
Let's explain a bit...
RouterModule.forRoot([])
establishes a lot of necessary providers and functionality required for routing. Under the hood, all router modules roll-up the route definition to an injection token named ROUTES
. We can bootstrap the module and immediately provide a new value on-top for the ROUTES
value.
To allow our shell app to have it's own built-in routes as well as the dynamic runtime routes, we use a factory to concat rootRoutes
and the dynamicRoutes (from our injection token PLATFORM_ROUTES
).
Lastly, we have a fallback route, as routes will execute first-to-last, to handle global redirect behavior for unhandled routes.
Conclusion
At this point, we are rolling. We can now change our config while serving the different remotes and shell and see it swap out the served bundle. In a real environment, the config data would come from an endpoint.
If you read this far I appreciate it. Module federation in Angular is a very new concept and I welcome feedback and questions on this topic!
Top comments (16)
So with my project setup like the article describes I got an error when the application loaded about can't mix multi-providers and regular providers. So I ended up adding this to the route module code:
Specifically I added multi:true but now I get the following error:
Error: NG0200: Circular dependency in DI detected for InjectionToken ROUTES
I just don't even know where to start to debug this. I don't see any circular dependencies visually inspecting the code. However, I'm concerned I had to add multi: true to even get this far and the example does not. I am using Angular 12.0.10.
Thanks for any help you might be able to provide!
I found the problem. It does not like the fact that you are providing ROUTES and also importing routes as a dependency in the provider. Changed static routes defined to something else and this corrected the problem.
Thank you for the reply it saved me a lot of time, I will just add on to your answer for more clarity for those in the future who will face this problem.
In the app-routing.module.ts
So we just add multi: true and remove ROUTES from the deps array will stop giving this error.
Most of the samples you can find online assume that you have a monerepo. Do you know of any example that deals with the shell and mfes being in separate repos? What would happen to the shared dependencies? Will I still be able to download them just once?
Excellent question, I am not currently aware of any examples of cross-repo MFE; although in concept they should work the same.
The usage of mono-repos, only simplifies set-up and maintenance; by making use of schematics to generate webpack configs and establish the remote entries.
You should be able to do this all manually, pointing your remote entry to whatever port your other repository/project is on. The handshake for dependencies should also be the same, since it's evaluated at runtime against the generated bundles (independent of anything mono-repo specific).
Awesome implementation of platform-config.json, read the article it's amazing, I will be implementing this for the dynamic mfe remoteEntry points. Also I had a question about the usage of cdn, if I have extra business logic like making calls to another server in my exposed remote module and if I hosted that particular mfe in a cdn will it work out of the box or will CORS give me trouble? Basically i know i will have to try it and seek the answers just wanted to know your experience using cdns and is there any references which helped you out, it would be great if you can point me towards it😁.
Hi, Great post btw!
I am currently working into something similar, but I am facing some trouble. What happens if you want your MFE to have some routing inside? i.e.: your LoginModule has routing to go to a 2FA view or a ForgotMyPassword view? That routing should be inside the MFE, and I'm not able to navigate there correctly from the Shell?
In addition, could you please share a github URL with the source code of this example?
Many thanks!! :)
Nice!
But how to test "loadRemoteModule" using jasmine?
Non of my tries to mock this function are working.
Please see
here (stackoverflow)
and
here (github / module federation)
What do I miss?
Hello Sean,
Great article. Thank you for sharing.
I'm trying to do what you advise against: exposing AppModule and AppComponent.
So what I'm trying to achieve is the following:
With your advice the code structure at remote app level will look like:
app.module.ts
|- feature.module.ts
__|- sub-feature1.module.ts
__|- sub-feature2.module.ts
By exposing tha app.module.ts and app.component.ts I would only have 1 router-outlet and flat structure at remote app level:
app.module.ts
_ |- feature1.module.ts
_ |- feature2.module.ts
Unfortunately I hit the wall with this approach (exposing the app.module.ts and app.component.ts). When the host loads the remote, the remote's sub-route gets loaded in the host's router-outlet, without running the remote's app.component.ts (nothing runs from the remote's app.component.ts and the content of app.component.html is not rendered).
So I figured I can enforce it by using named outlets. However it just breaks the app completely.
I also tried to rename the remote's app.component and app.module to avoid possible name collision, but nothing changed.
Do you know what causes the problem?
In your example, does the
feature.module.ts
have a component with a router outlet and a router module declaration forforChild([...])
? By exposingfeature.module.ts
in your remote's webpack config, you should be able to something similar to this in your shell/root's app routing module:This should lazy-load your remote app's feature module contents when navigating to
/feature-path
and then defer to that module's structure for further nested lazy loading. I've done something similar where my shell loads different authentication experiences under the/authentication
namespace and certain auth experiences have further lazy loading for forgot password & user registration screens.If you can statically declare the module federation information, it's much easier. Otherwise, you'll have to have that information in a config and pass static tokens into the app module of the shell and override the
ROUTES
token as shown above.If you eco-system is small enough or not having live reloading against the remote isn't a deal breaker, you can federate the
AppModule
from the remote and not run into any issues.Is it possible running two versions of Angular on the same page? One for Host and a different version for Remotes?
Yes, it should be possible. I'd be cautious with doing this over major versions; as you're pulling in the remote app into the context of the host. One of the many benefits of micro frontends and module federation, is that you can pull in different frameworks, versions, etc.; so that you can independently manage slices of your application and update and deploy those changes.
If your two Angular applications are using Angular Elements, you may need to share the
PlatformRef
between these (via the window global). There's a great walkthrough of this in the link at the top of this article.Hi Sean
Thank you for the response!
I'm currently testing with:
Shell
shared
configured with @angular/*singleton: true
Remote
shared
configured with @angular/*singleton: false
When Shell injects Remote with
loadRemoteModule
lazy routing;node_modules_angular_core___ivy_ngcc___fesm2015_core_js (Angular 12.0.5 chunk from Remote)
JS file is added from Remote. This immediately fires a run-time error:Error: inject() must be called from an injection context
. From what I have found, the error is caused by two Angular instances running concurrently.If I updated shared configured with
Shell
shared
configured with @angular/*singleton: true
Remote
shared
configured with @angular/*singleton: true
The Remote; is lazy loaded successfully, loaded on
<router-outlet>
but Remote module is rendered from the single @angular/core instance from Shell. Not ideal.I have multiple micro-frontends running on a single Shell instance. I won't be able to necessarily keep all micro-frontends up to date with latest Angular, and Shell instance might move ahead in versions. Thought module federation would have some solutions.
Nice Article! How to pass the injection token from the shell to the remote app?
stackoverflow.com/questions/691373...
You are unable to pass injection token instances between boundaries (shell to remote) at this time. It will always create a new instance, so you can use factories if you expect a default value. Instead of sharing tokens, you are likely better off using a service instance and sharing that between shell to remote. You can also use service workers, DOM events and other messaging-based solutions to pass the needed context you would otherwise be using a token for.
Congrats... could you please share a github URL with the source code of this example?