This article is divided in 2 parts:
As your tackling bigger and bigger problems you will face the reality that you application needs to adapt to the ever-growing complexity and size.
It’s not only the application that grows, but also the team alongside it. Both requires ways of dealing with complexity and healthy scaling.
Enter Microservices.
Microservices is an architectural style that structures an application as a collection of services that are:
Source: Microservices.io
In essence we want to divide the application in separate services based on the Domain specific functionality they will responsible for.
In the Backend world this architecture it’s been used and studied for quite a long time.
Only recently we’ve seen the rise of this architecture style in Frontend Applications.
When it comes to the FE layer we can do a similar exercise and decompose our application into smaller units based on screens representing domain-specific functionality.
An architectural style where independently deliverable frontend applications are composed into a greater whole.
Source: Martinfowler.com
In this article I’d like to walk you through a possible real world use case for this architectural pattern.
I want to focus on high-level concept and how they apply to an Angular application.
This is not a comprehensive list of all the pros and cons of this approach but it should give you enough context to create a good mental model for yourself and your team.
Our team has been tasked to build a unified experience for our end-users that brings different areas of the business together.
Our good Architects have decided to split the Application based on 3 different services based on the Domain specific functionality they will responsible for.
Very common scenario in Enterprise development.
Ok. Let’s get to work!
There are many way of implementing this pattern:
Based on your need you will pick probably on of those but the one solution that I’m most exited about is Webpack Module Federation.
Module federation allows a JavaScript application to dynamically load code from another application bundle while sharing dependencies in the process. If an application consuming a federated module does not have a dependency needed by the federated code Webpack will download the missing dependency from that federated build origin.
Federated code can always load its dependencies but will attempt to use the consumers’ dependencies before downloading more payload. Less code duplication, dependency sharing just like a monolithic Webpack build.
This system is designed so that each completely standalone build/app can be in its own repository, deployed independently, and run as its own independent SPA.
Module Federation | Micro-FE framework |
---|---|
The primary purpose of Module Federation is to share code between applications. | The primary purpose of Micro-FE frameworks is to share UI between applications |
Module Federation, on the other hand, has one job–get Javascript from one application into another. | A Micro-FE framework has two jobs; first, load the UI code onto the page and second, integrate it in a way that’s platform-agnostic |
A simple way to think about Module Federation is: it makes multiple independent applications work together to look and feel like a monolith, at runtime.
🚨 If you want to know more about this I’ve found this book (The Practical Guide to Module Federation) very valuable.
The great thing about Module Federation is that the codebase is not aware of the Micro-fronted details since Webpack behind the scene manages everything.
All you need to do is to extend your Angular Webpack configuration (webpack.config.js
) and the ModuleFederationPlugin
with the required settings.
For example:
// Shell Webpack config
new ModuleFederationPlugin({
name: "shell",
library: { type: "var", name: "shell" },
filename: "remoteEntry.js",
remotes: {
appOne: 'appOne@http://localhost:4201/remoteEntry.js}',
},
shared: {
"@angular/core": { singleton: true, eager: true, strictVersion: true, requiredVersion: deps["@angular/core"]},
"@angular/common": { singleton: true, eager: true, strictVersion: true, requiredVersion: deps["@angular/common"] },
"@angular/router": { singleton: true, eager: true, strictVersion: true, requiredVersion: deps["@angular/router"] },
},
}),
// Shell routing.module.ts
export const APP_ROUTES: Routes = [
{
path: "",
component: HomeComponent,
pathMatch: "full",
},
{
path: "one-path",
loadChildren: () =>
loadRemoteModule({
remoteName: "appOne",
exposedModule: "Module",
}).then((m) => m.appOne),
},
];
This architecture will give us the maximum team autonomy, encapsulation and fulfil all the architecture goals above mentioned.
Pretty cool ah? 😁
We’ve discussed the advantages but here’s summary of the drawbacks:
Before we dive into the code let’s take a look at the specific terminology that you will encounter.
Field | Description |
---|---|
name |
This is the name of the application. Never have conflicting names, always make sure every federated app has a unique name |
filename |
This is the filename to use for the remote entry file. The remote entry file is a manifest of all of the exposed modules and the shared libraries |
remotes |
These are the names of the other federated module applications that this application will consume code from. For example, if this home application consumes code from an application called nav that will have the header, then nav is a “remote” and should be listed here |
shared |
This is a list of all the libraries that this application will share with other applications in support of files listed in the exposes section. For example, if you export a React component, you will want to list react in the shared section because it’s required to run your code |
Source: Jack Herrington and Zack Jackson. “Practical Module Federation”
We’re going to create 3 different Angular applications.
Let’s bootstrap the 3 different apps wit Angular CLI: ng new <name>
root/
├── shell/
│ ├── angular.json
│ ├── webpack.config.json
│ ├── ...
├── app-one/
│ ├── angular.json
│ ├── webpack.config.json
│ ├── ...
├── app-two/
│ ├── angular.json
│ ├── webpack.config.json
│ ├── ...
🚨 You must use
yarn
because it’s simpler to overrides packages (we need to use Webpack 5. Angular CLI ships only Webpack 4)
In package.json
add the following entry:
"resolutions": {
"webpack": "^5.25.0"
},
You also need to use a different builder and tell Angular to use your Webpack Config:
}
...
"architect": {
"build": {
"builder": "ngx-build-plus:browser",
"options": {
....
"extraWebpackConfig": "./webpack.config.js"
}
}
}
}
Now let’s take a look at the plugin configuration in webpack.config.js
for the Shell
.
const webpack = require("webpack");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
const deps = require("./package.json").dependencies;
module.exports = {
output: {
publicPath: "http://localhost:4200/",
uniqueName: "shell",
},
optimization: {
runtimeChunk: false,
},
plugins: [
new ModuleFederationPlugin({
name: "shell",
library: { type: "var", name: "shell" },
filename: "remoteEntry.js",
// exposes: {
// Can expose anything: modules, state etc...
// },
remotes: {
appOne: 'appOne@http://localhost:4201/remoteEntry.js}',
appTwo: 'appTwo@http://localhost:4202/remoteEntry.js}'
},
shared: {
"@angular/core": { singleton: true, eager: true, strictVersion: true, requiredVersion: deps["@angular/core"]},
"@angular/common": { singleton: true, eager: true, strictVersion: true, requiredVersion: deps["@angular/common"] },
"@angular/router": { singleton: true, eager: true, strictVersion: true, requiredVersion: deps["@angular/router"] },
// You can build this list automatically
},
}),
],
};
For AppOne
and AppTwo
the configuration is basically the same. The only difference is that we want to expose the modules:
new ModuleFederationPlugin({
name: "appOne",
library: { type: "var", name: "appOne" },
filename: "remoteEntry.js",
exposes: {
appOneModule: "./src/app/appOne/appOne.module.ts",
'./RemoteComponent': './src/app/remoteComponent/remote.component.ts' // If we want we can also share individual component and not only Modules
},
shared: {
"@angular/core": { singleton: true, eager: true, strictVersion: true, requiredVersion: deps["@angular/core"]},
"@angular/common": { singleton: true, eager: true, strictVersion: true, requiredVersion: deps["@angular/common"] },
"@angular/router": { singleton: true, eager: true, strictVersion: true, requiredVersion: deps["@angular/router"] },
},
}),
utils
in src/app
federation-utils.ts
and route-utils.ts
// federation-utils.ts
type Scope = unknown;
type Factory = () => any;
type Container = {
init(shareScope: Scope): void;
get(module: string): Factory;
};
declare const __webpack_init_sharing__: (shareScope: string) => Promise<void>;
declare const __webpack_share_scopes__: { default: Scope };
const moduleMap: any = {};
function loadRemoteEntry(remoteEntry: string): Promise<void> {
return new Promise<any>((resolve, reject) => {
if (moduleMap[remoteEntry]) {
resolve(undefined);
return;
}
const script = document.createElement('script');
script.src = remoteEntry;
script.onerror = reject;
script.onload = () => {
moduleMap[remoteEntry] = true;
resolve(undefined); // window is the global namespace
};
document.body.append(script);
});
}
async function lookupExposedModule<T>(
remoteName: string,
exposedModule: string
): Promise<T> {
// Initializes the share scope. This fills it with known provided modules from this build and all remotes
await __webpack_init_sharing__('default');
const container = (window as any)[remoteName] as Container; // or get the container somewhere else
// Initialize the container, it may provide shared modules
await container.init(__webpack_share_scopes__.default);
const factory = await container.get(exposedModule);
const Module = factory();
return Module as T;
}
export type LoadRemoteModuleOptions = {
remoteEntry: string;
remoteName: string;
exposedModule: string;
};
export async function loadRemoteModule<T>(
options: LoadRemoteModuleOptions
): Promise<T> {
await loadRemoteEntry(options.remoteEntry);
return await lookupExposedModule<T>(
options.remoteName,
options.exposedModule
);
}
// route-utils.ts
// imports excluded for sake of brevity
export function buildRoutes(options: Microfrontend[]): Routes {
const lazyRoutes: Routes = options.map((o) => ({
path: o.routePath,
loadChildren: () => loadRemoteModule<any>(o).then((m) => m[o.ngModuleName]),
}));
return [...APP_ROUTES, ...lazyRoutes];
}
microfrontends
in src/app
microfrontend.model.ts
and microfrontend.service.ts
// microfrontend.model.ts
// imports excluded for sake of brevity
export type Microfrontend = LoadRemoteModuleOptions & {
displayName: string;
routePath: string;
ngModuleName: string;
};
// microfrontend.service.ts
// imports excluded for sake of brevity
@Injectable({ providedIn: 'root' })
export class MicrofrontendService {
microfrontends: Microfrontend[] | undefined;
constructor(private router: Router) {}
/*
* Initialize is called on app startup to load the initial list of
* remote microfrontends and configure them within the router
* Error handling somewhere?????
*/
initialise(): Promise<void> {
return new Promise<void>((resolve, reject) => {
this.microfrontends = this.loadConfig();
console.log(this.microfrontends);
this.router.resetConfig(buildRoutes(this.microfrontends));
resolve();
});
}
/*
* Hardcoded list of remote microfrontends. Use Look-up Service in Production
*/
loadConfig(): Microfrontend[] {
return [
{
// For Loading
remoteEntry: 'http://localhost:4201/remoteEntry.js',
remoteName: 'appOne',
exposedModule: 'AppOneModule',
// For Routing
displayName: 'App One',
routePath: 'app-one',
ngModuleName: 'AppOneModule',
},
{
// For Loading
remoteEntry: 'http://localhost:4202/remoteEntry.js',
remoteName: 'appTwo',
exposedModule: 'AppTwoModule',
// For Routing
displayName: 'App Two',
routePath: 'app-two',
ngModuleName: 'AppTwoModule',
},
];
}
}
app.routes.ts
// imports excluded for sake of brevity
export const APP_ROUTES: Routes = [
{ path: '', redirectTo: 'home', pathMatch: 'full' },
{
path: 'home',
loadChildren: () =>
import('./Shell/shell-routing.module').then((m) => m.ShellRoutingModule), // Main component
},
];
app.module.ts
to glue everything together// imports excluded for sake of brevity
export function initializeApp(
mfService: MicrofrontendService
): () => Promise<void> {
return () => mfService.initialise();
}
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
RouterModule.forRoot(APP_ROUTES),
],
providers: [
MicrofrontendService,
{
provide: APP_INITIALIZER,
useFactory: initializeApp,
multi: true,
deps: [MicrofrontendService],
},
],
bootstrap: [AppComponent],
})
export class AppModule {}
app-routing.module.ts
// imports excluded for sake of brevity
export const routes: Routes = [
{ path: '', redirectTo: 'home', pathMatch: 'full' },
{
path: 'home',
loadChildren: () =>
import('./Shell/shell.module').then((m) => m.ShellModule),
},
{
path: 'app-one',
loadChildren: () =>
loadRemoteModule<any>({
remoteName: 'appOne',
remoteEntry: 'http://localhost:4201/remoteEntry.js',
exposedModule: 'AppOneModule',
})
.then((m) => m.AppOneModule)
.catch((e) => {
console.log(e);
// Log somewhere!
}),
},
{
path: 'app-two',
loadChildren: () =>
loadRemoteModule<any>({
remoteName: 'appTwo',
remoteEntry: 'http://localhost:4202/remoteEntry.js',
exposedModule: 'AppTwoModule',
})
.then((m) => {
console.log(m);
return m.AppTwoModule;
})
.catch((e) => {
console.log(e);
// Log somewhere!
}),
},
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
})
export class AppRoutingModule {}
Now your Shell
app will be able to lazy load the Federated App One or App Two based on the route.
🚢 That’s it!
Part of the code is reflected in this repo.
Probably having the full GitHub repo would be better to understand the flow of the code but I need to remove some private things before publishing.
I will update the post once it is ready.
I haven’t covered all the edge-cases and nuances of this approach. I highly reccomend to read this article series by Angular Architects. It’s very well explained and I used that as a base for getting started with Module Federation.
Big thanks to Zack Jackson (Inventor & co-creator of Module Federation) for the amazing work that he did with Module Federation and with the docs.
If you have any suggestions, questions, corrections or if you want to add anything please DM or tweet me: @zanonnicola