Building Micro-frontends based on Angular

image.png Have you been wondering how to build an Angular Micro-frontend? You are in the right place!

Benefits of Micro Frontend Architecture

Automation of the CI/CD workflow

Because each app integrates and deploys separately, the CI/CD pipeline is simplified. Because all functionalities are distinct, you don't have to be concerned about the entire program while introducing a new feature. If there is a little issue in a module's code, the CI/CD pipeline will fail the entire build process.

Flexibility of teams

Numerous teams can bring value to multiple systems while working independently.

Single responsibility

Using this technique, each team is able to produce components with a single responsibility. Each Micro Frontend team is completely focused on the functionality of its Micro Frontend.

Reusability

Code reusability means that you will be able to utilize it in various places. Multiple teams can reuse a single module that has been built and delivered.

Technology agnosticism

Micro Frontend design is technology agnostic. Components from several web development frameworks can be used (React, Vue, Angular, etc.).

Simple learning

Smaller modules are easier for new developers to learn and grasp than a monolithic system with a large code structure.

Getting started

The diagram below depicts a micro frontend architecture

image.png

image.png

This section has at least two components that are ready to be exported from this module. First and foremost, we must establish a new app and configure a custom angular builder — This builder allows us to use custom webpack configs.

$ ng new layout

$ npm i --save-dev ngx-build-plus

Now, at the root of our project, we must create the webpack.config.js and webpack.prod.config.js files.

const webpack = require("webpack");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
module.exports = {
  output: {
    publicPath: "http://localhost:4201/",
    uniqueName: "layout",
  },
  optimization: {
    runtimeChunk: false,
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "layout",
      library: { type: "var", name: "layout" },
      filename: "remoteEntry.js",
      exposes: {
        Header: "./src/app/modules/layout/header/header.component.ts",
        Footer: "./src/app/modules/layout/footer/footer.component.ts",
      },
      shared: {
        "@angular/core": { singleton: true, requiredVersion: "auto" },
        "@angular/common": { singleton: true, requiredVersion: "auto" },
        "@angular/router": { singleton: true, requiredVersion: "auto" },
      },
    }),
  ],
};
module.exports = require("./webpack.config");

image.png

We can specify the minimum needed version, if two or more versions of a package are permitted, and so on. More information regarding various plugin choices may be found here.

image.png

Then, in angular.json , add a custom config file and set the default builder to ngx-build-plus

...
"projects": {
    "layout": {
        "projectType": "application",
        "schematics": {
            "@schematics/angular:component": {
                "style": "scss"
            },
            "@schematics/angular:application": {
                "strict": true
            }
        },
        "root": "",
        "sourceRoot": "src",
        "prefix": "app",
        "architect": {
            "build": {
                "builder": "ngx-build-plus:browser",
                "options": {
                    "outputPath": "dist/layout",
                    "index": "src/index.html",
                    "main": "src/main.ts",
                    "polyfills": "src/polyfills.ts",
                    "tsConfig": "tsconfig.app.json",
                    "inlineStyleLanguage": "scss",
                    "assets": [
                        "src/favicon.ico",
                        "src/assets"
                    ],
                    "styles": [
                        "src/styles.scss"
                    ],
                    "scripts": [],
                    "extraWebpackConfig": "webpack.config.js"
                },
                "configurations": {
                    "production": {
                        "budgets": [{
                                "type": "initial",
                                "maximumWarning": "500kb",
                                "maximumError": "1mb"
                            },
                            {
                                "type": "anyComponentStyle",
                                "maximumWarning": "2kb",
                                "maximumError": "4kb"
                            }
                        ],
                        "extraWebpackConfig": "webpack.prod.config.js",
                        "fileReplacements": [{
                            "replace": "src/environments/environment.ts",
                            "with": "src/environments/environment.prod.ts"
                        }],
                        "outputHashing": "all"
                    },
                    "development": {
                        "buildOptimizer": false,
                        "optimization": false,
                        "vendorChunk": true,
                        "extractLicenses": false,
                        "sourceMap": true,
                        "namedChunks": true
                    }
                },
                "defaultConfiguration": "production"
            },
            "serve": {
                "builder": "ngx-build-plus:dev-server",
                "configurations": {
                    "production": {
                        "browserTarget": "layout:build:production"
                    },
                    "development": {
                        "browserTarget": "layout:build:development",
                        "extraWebpackConfig": "webpack.config.js",
                        "port": 4205
                    }
                },
                "defaultConfiguration": "development"
            },
            "test": {
                "builder": "ngx-build-plus:karma",
                "options": {
                    "main": "src/test.ts",
                    "polyfills": "src/polyfills.ts",
                    "tsConfig": "tsconfig.spec.json",
                    "karmaConfig": "karma.conf.js",
                    "inlineStyleLanguage": "scss",
                    "assets": [
                        "src/favicon.ico",
                        "src/assets"
                    ],
                    "styles": [
                        "src/styles.scss"
                    ],
                    "scripts": [],
                    "extraWebpackConfig": "webpack.config.js"
                }
            }
        }
    }
},
"defaultProject": "layout"
}

Register Page Module Federation

This web application will house all of the logic for the login/registration page. The fundamental routine is nearly same; we need to create a new app and install a custom builder in order to use custom webpack configurations

$ ng new registration

$ npm i --save-dev ngx-build-plus

Following that, we must construct webpack.config.js and webpack.prod.config.js

// webpack.config.js
const webpack = require("webpack");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
module.exports = {
  output: {
    publicPath: "http://localhost:4202/",
    uniqueName: "registration",
  },
  optimization: {
    runtimeChunk: false,
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "registration",
      library: { type: "var", name: "registration" },
      filename: "remoteEntry.js",
      exposes: {
        RegistrationModule:
          "./src/app/modules/registration/registration.module.ts",
      },
      shared: {
        "@angular/core": { singleton: true, requiredVersion: "auto" },
        "@angular/common": { singleton: true, requiredVersion: "auto" },
        "@angular/router": { singleton: true, requiredVersion: "auto" },
      },
    }),
  ],
};
module.exports = require("./webpack.config");

As you can see, we just export RegistrationModule here. In our shell program, we may utilize this module as a lazy-loaded module. In addition, we must change the default builder to ngx-build-plus and include webpack configurations in the angular JSON file — The same as we did for the Header & Footer module before.

Dashboard Module Federation

This module displays certain information to a logged-in user. The same method as for the Register page, but with personal webpack configurations:

// webpack.config.js
const webpack = require("webpack");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
module.exports = {
  output: {
    publicPath: "http://localhost:4203/",
    uniqueName: "dashboard",
  },
  optimization: {
    runtimeChunk: false,
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "dashboard",
      library: { type: "var", name: "dashboard" },
      filename: "remoteEntry.js",
      exposes: {
        DashboardModule: "./src/app/modules/dashboard/dashboard.module.ts",
      },
      shared: {
        "@angular/core": { singleton: true, requiredVersion: "auto" },
        "@angular/common": { singleton: true, requiredVersion: "auto" },
        "@angular/router": { singleton: true, requiredVersion: "auto" },
      },
    }),
  ],
};

Shell App Module Federation

The main app is responsible for combining all of the individual micro frontend modules into a single app. As previously, we construct a new app using a custom Angular builder

$ ng new shell
$ npm i --save-dev ngx-build-plus

Add your own webpack configurations

const webpack = require("webpack");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
module.exports = {
  output: {
    publicPath: "http://localhost:4200/",
    uniqueName: "shell",
  },
  optimization: {
    runtimeChunk: false,
  },
  plugins: [
    new ModuleFederationPlugin({
      shared: {
        "@angular/core": { eager: true, singleton: true },
        "@angular/common": { eager: true, singleton: true },
        "@angular/router": { eager: true, singleton: true },
      },
    }),
  ],
};

But first, we need to add webpack configuration with the custom builder to the angular.json file. All module configurations are declared in environment/environment.ts (for the production version, the localhost address must be replaced with the deployed public address)

export const environment = {
  production: false,
  microfrontends: {
    dashboard: {
      remoteEntry: "http://localhost:4203/remoteEntry.js",
      remoteName: "dashboard",
      exposedModule: ["DashboardModule"],
    },
    layout: {
      remoteEntry: "http://localhost:4201/remoteEntry.js",
      remoteName: "layout",
      exposedModule: ["Header", "Footer"],
    },
  },
};

Then, when applicable, we must include a loading Dashboard and a registration page. First and foremost, we must develop module federation utilities that will allow us to import remote modules from other programs.

// src/app/utils/federation-utils.ts
type Scope = unknown;
type Factory = () => any;
interface 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: Record<string, boolean> = {};
function loadRemoteEntry(remoteEntry: string): Promise<void> {
  return new Promise<void>((resolve, reject) => {
    if (moduleMap[remoteEntry]) {
      return resolve();
    }
    const script = document.createElement("script");
    script.src = remoteEntry;
    script.onerror = reject;
    script.onload = () => {
      moduleMap[remoteEntry] = true;
      resolve(); // 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[remoteName] as Container;
  // 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 interface LoadRemoteModuleOptions {
  remoteEntry: string;
  remoteName: string;
  exposedModule: string;
}
export async function loadRemoteModule<T = any>(
  options: LoadRemoteModuleOptions
): Promise<T> {
  await loadRemoteEntry(options.remoteEntry);
  return lookupExposedModule<T>(options.remoteName, options.exposedModule);
}

There are several utilities for creating lazily loaded routes

// src/app/utils/route-utils.ts
import { Routes } from "@angular/router";
import { loadRemoteModule } from "./federation-utils";
import { APP_ROUTES } from "../app.routes";
import { Microfrontend } from "../core/services/microfrontends/microfrontend.types";
export function buildRoutes(options: Microfrontend[]): Routes {
  const lazyRoutes: Routes = options.map((o) => ({
    path: o.routePath,
    loadChildren: () => loadRemoteModule(o).then((m) => m[o.ngModuleName]),
    canActivate: o.canActivate,
    pathMatch: "full",
  }));
  return [...APP_ROUTES, ...lazyRoutes];
}

Then we must create a micro frontend service

// src/app/core/services/microfrontends/microfrontend.service.ts
import { Injectable } from "@angular/core";
import { Router } from "@angular/router";
import { MICROFRONTEND_ROUTES } from "src/app/app.routes";
import { buildRoutes } from "src/app/utils/route-utils";
@Injectable({ providedIn: "root" })
export class MicrofrontendService {
  constructor(private router: Router) {}
  /*
   * Initialize is called on app startup to load the initial list of
   * remote microfrontends and configure them within the router
   */
  initialise(): Promise<void> {
    return new Promise<void>((resolve) => {
      this.router.resetConfig(buildRoutes(MICROFRONTEND_ROUTES));
      return resolve();
    });
  }
}

In addition, file for type

// src/app/core/services/microfrontends/microfrontend.types.ts
import { LoadRemoteModuleOptions } from "src/app/utils/federation-utils";
export type Microfrontend = LoadRemoteModuleOptions & {
  displayName: string;
  routePath: string;
  ngModuleName: string;
  canActivate?: any[];
};

Then we must declare remote modules based on the routes

// src/app/app.routes.ts
import { Routes } from "@angular/router";
import { LoggedOnlyGuard } from "./core/guards/logged-only.guard";
import { UnloggedOnlyGuard } from "./core/guards/unlogged-only.guard";
import { Microfrontend } from "./core/services/microfrontends/microfrontend.types";
import { environment } from "src/environments/environment";
export const APP_ROUTES: Routes = [];
export const MICROFRONTEND_ROUTES: Microfrontend[] = [
  {
    ...environment.microfrontends.dashboard,
    exposedModule: environment.microfrontends.dashboard.exposedModule[0],
    // For Routing, enabling us to ngFor over the microfrontends and dynamically create links for the routes
    displayName: "Dashboard",
    routePath: "",
    ngModuleName: "DashboardModule",
    canActivate: [LoggedOnlyGuard],
  },
  {
    ...environment.microfrontends.registration,
    exposedModule: environment.microfrontends.registration.exposedModule[0],
    displayName: "Register",
    routePath: "signup",
    ngModuleName: "RegistrationModule",
    canActivate: [UnloggedOnlyGuard],
  },
];

Also, in the main app module, utilize our Micro Frontend Service

// src/app/app.module.ts
import { APP_INITIALIZER, NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { RouterModule } from "@angular/router";
import { AppRoutingModule } from "./app-routing.module";
import { AppComponent } from "./app.component";
import { APP_ROUTES } from "./app.routes";
import { LoaderComponent } from "./core/components/loader/loader.component";
import { NavbarComponent } from "./core/components/navbar/navbar.component";
import { MicrofrontendService } from "./core/services/microfrontends/microfrontend.service";
export function initializeApp(
  mfService: MicrofrontendService
): () => Promise<void> {
  return () => mfService.initialise();
}
@NgModule({
  declarations: [AppComponent, NavbarComponent, LoaderComponent],
  imports: [
    BrowserModule,
    AppRoutingModule,
    RouterModule.forRoot(APP_ROUTES, { relativeLinkResolution: "legacy" }),
  ],
  providers: [
    MicrofrontendService,
    {
      provide: APP_INITIALIZER,
      useFactory: initializeApp,
      multi: true,
      deps: [MicrofrontendService],
    },
  ],
  bootstrap: [AppComponent],
})
export class AppModule {}

We must now load the Footer and Header components. To do so, we must edit the app component

// src/app/app.component.html
<main>
    <header #header></header>
    <div class="content">
        <app-navbar [isLogged]="auth.isLogged"></app-navbar>
        <div class="page-content">
            <router-outlet *ngIf="!loadingRouteConfig else loading"></router-outlet>
            <ng-template #loading>
                <app-loader></app-loader>
            </ng-template>
        </div>
    </div>
    <footer #footer></footer>
</main>

Then the file src/app/app.component.ts will look something like this

import {
  ViewContainerRef,
  Component,
  ComponentFactoryResolver,
  OnInit,
  AfterViewInit,
  Injector,
  ViewChild,
} from "@angular/core";
import {
  RouteConfigLoadEnd,
  RouteConfigLoadStart,
  Router,
} from "@angular/router";
import { loadRemoteModule } from "./utils/federation-utils";
import { environment } from "src/environments/environment";
@Component({
  selector: "app-root",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.scss"],
})
export class AppComponent implements AfterViewInit, OnInit {
  @ViewChild("header", { read: ViewContainerRef, static: true })
  headerContainer!: ViewContainerRef;
  @ViewChild("footer", { read: ViewContainerRef, static: true })
  footerContainer!: ViewContainerRef;
  loadingRouteConfig = false;
  constructor(
    private injector: Injector,
    private resolver: ComponentFactoryResolver,
    private router: Router
  ) {}
  ngOnInit() {
    this.router.events.subscribe((event) => {
      if (event instanceof RouteConfigLoadStart) {
        this.loadingRouteConfig = true;
      } else if (event instanceof RouteConfigLoadEnd) {
        this.loadingRouteConfig = false;
      }
    });
  }
  ngAfterViewInit(): void {
    // load header
    loadRemoteModule({
      ...environment.microfrontends.layout,
      exposedModule: environment.microfrontends.layout.exposedModule[0],
    }).then((module) => {
      const factory = this.resolver.resolveComponentFactory(
        module.HeaderComponent
      );
      this.headerContainer?.createComponent(factory, undefined, this.injector);
    });
    // load footer
    loadRemoteModule({
      ...environment.microfrontends.layout,
      exposedModule: environment.microfrontends.layout.exposedModule[1],
    }).then((module) => {
      const factory = this.resolver.resolveComponentFactory(
        module.FooterComponent
      );
      this.footerContainer?.createComponent(factory, undefined, this.injector);
    });
  }
}

We include logic for loaders as well as reasoning for lazy-loaded components here (Header, Footer).

Conclusion

As frontend codebases get more advanced, there is a growing desire for more maintainable micro frontend structures. As a result, the ability to set clear boundaries that provide the appropriate levels of coupling and cohesiveness between technical and domain entities is critical, as is the ability to scale software delivery across separate, autonomous teams.