The case for Micro Frontends: Angular version

April 17, 2021 ☼ angularjs

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.

What do you mean by Micro Frontends?

An architectural style where independently deliverable frontend applications are composed into a greater whole.

Key benefits:

Diagram

Source: Martinfowler.com

Real world use case

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.

Context

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.

Requirements:

  1. Isolation - Teams can work on their Sub-Domain without affecting or being affected by the others
  2. Common Dependencies - Teams can access a pool of common dependences such as UI components
  3. Separate development/Deployment cycles - Teams own the development/deployment cycle and can agree on conventions/processes
  4. Autonomous teams
  5. Avoid accidental coupling between Sub-Domains
  6. Maintainable codebases
  7. Deliver great UX and performance
  8. Architecture should scale along side with the growing team

Ok. Let’s get to work!

Which Micro Frontend framework?

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

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.

Difference between MF Framework and Module Federation

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.

Angular point of view

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:

Glossary

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”

Runtime Diagram

Part 2 - How do I integrate Module Federation in my Angular App?

We’re going to create 3 different Angular applications.

  1. The Shell App. Main entry point of our User Experience and in charge of lazy loading the federated Apps. The Shell will have some common elements like header and footer and can also share state with the other apps
  2. App One. Federated app, developed and deployed in isolation
  3. App Two. Federated app, developed and deployed in isolation

Apps Diagram

Set up

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"] },
      },
    }),

How to load the federated modules

  1. Create a folder called utils in src/app
  2. You need 2 files: 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];
}
  1. Create a folder called microfrontends in src/app
  2. You need 2 files: 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',
      },
    ];
  }
}
  1. Create 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
  },
];
  1. Modify 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 {}
  1. Last piece: 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!

End Notes

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.

Useful resources and credits


If you have any suggestions, questions, corrections or if you want to add anything please DM or tweet me: @zanonnicola