Integrating micro frontends into existing applications – lessons learned

Christian Siebmanns

The application has been in production for several years – can you jump on a hype like micro frontends in such a scenario? We took the plunge and redesigned our existing frontend to incorporate the micro frontends pattern. What motivated us to do this and what we learned along the way, you will discover in this article.

Two teams and lots of reusability

We had the following situation: Two project teams within the same company develop web portals independently from each other. However, management had mandated that as many components as possible should be shared and reused among the two projects. Reusability was a core goal set for both projects.

Due to the use of a microservice architecture, it was easy for the backend. For instance, the authentication microservice was shared between the projects. However, each web portal would host its own instance of shared microservices.

In the frontend, reusability was also possible in many areas: The design system as well as the login workflow were shared across projects, for example.

Another example of frontend reusability was the central administration application. This application allows specialist users to setup new principals, lock individual users, create system-wide announcements, and so on. These functionalities on the backend live within the authentication microservice. Since this microservice is shared between projects, it made sense to share the frontend as well.

Problems arise

The web portals worked without a hitch in production like this for many years. However, then we were handed a user story saying that we should add a cleanup function for specific data. The problem here is that the cleanup happens in a specific microservice that is not shared between projects. This sounds trivial, but sadly it was not. Our initial approach was to integrate the functionality and only show it to the user based on the user’s authorities.

How we get types for backend communication automatically

We use TypeScript in our frontends for static type checking. To get types for our microservices’ APIs we generate an OpenAPI specification using a Maven plugin during the build process of the microservice. We then zip the specifications and use Maven to store them in our Nexus repository. In our frontend projects we then use a Maven POM to download the specifications for all of the necessary microservices. A tool then generates TypeScript types out of these specifications. Those types are for backend requests and responses. In theory this sounds awesome, but in practice it means that the frontend cannot be build before the necessary backends have been built, because we would be missing the API specifications for our types.

A quick and very dirty fix

To implement our user story we have to reference our specific microservice in our shared central administration application. Ouch! You can see a graphical representation of our dependency debacle in figure 1.

Figure 1: Our dependency hell that we created for ourselves.

Most of the times both projects coordinated a release date, when all release builds would be built in order. This worked really well for the first couple of releases, while we had added the dependency to the specialist microservice. However, suddenly one of the projects had to postpone its release date. To accommodate the shared components it was agreed upon that those would be built on the earliest release date to not block the other project.

This worked fine, until it came to the release build of the central administration application, since it was referencing the specialist microservice, which had not been built yet. The TypeScript types could not be generated and therefore the application could not be built. Temporarily, we could solve the situation quickly by changing the version of the API specification to use. The specification hadn’t changed since it was first introduced, so we just used the specification from the previous release.

However, it was clear that this could only be a temporary, dirty fix. So, we began looking for a more permanent solution. We thought about multiple different solutions, however extracting specialist project functionality into micro frontends certainly was the most appealing: we would have separate (build) projects for our micro frontends, which also meant we could build and release them independently from the main application.

To bundle our source code into the JavaScript code that is shipped to clients in production we use webpack. Its purpose is to orchestrate transpiling TypeScript into JavaScript and to optimize both the code and assets such as fonts and images. Thankfully, webpack 5 introduced a new feature called module federation especially built for micro frontends.

What is module Federation?

Zack Jackson, the creator of module federation, describes it as follows:

„Module federation allows a JavaScript application to dynamically load code from another application — in the process, sharing dependencies, 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.”

Jackson, Zack. Webpack 5 Module Federation: A game-changer in JavaScript architecture. [Online] https://medium.com/swlh/webpack-5-module-federation-a-game-changer-to-javascript-architecture-bcdd30e02669.

Webpack provides the ModuleFederationPlugin out of the box to define and load federated modules in a webpack application. A federated module is made up out of the following parts:

  • name is the name of the federated module and names its scope
  • remoteEntry is the entry point which is used to initialize the federated module
  • remotes are a list of federated modules that the given federated module loads and uses itself
  • exposes defines a list of elements that can be used by consuming federated modules
  • shared is an object that describes how dependencies are handled and if certain dependencies should be shared between federated modules (for example as a singleton)

Every webpack project can be a federated module. Listing 1 shows an example federated module that only exposes a file helpers.js at runtime.

const { ModuleFederationPlugin } = require("webpack").container;
const pluginConfig = {
plugins: [
    new ModuleFederationPlugin({
      name: "example",
      exposes: {
        "./helpers": "./src/helpers",
      },

    }),
  ],
}  

Listing 1: An example of a simple federated module that only exposes a single file.

Building plugins for our application

At the very beginning we asked ourselves what the API for our application should be. Since we could no longer bundle the code at build time, we had to create a suitable interface to extend our application through federated modules. We took inspiration from our Jetbrains development environments: They are massively extendable by plugins. This sounded like the perfect analogy for our application. Of course our plugin interface would be much simpler, since the plugins to load would be determined at runtime and users did not need to configure or manage plugins themselves. For users plugins should be invisible.

For our plugin architecture we built a TypeScript interface. This interface defines all the things necessary to to define a plugin:

  • a list of all the routes a plugin defines
  • a scope for the plugin, which is used to separate route, store, etc. entries from the rest of the application. It must be unique.
  • a render method which returns the elements a plugin has rendered for a given route.
  • an init method used to initialize the plugin and load its dependencies
  • several hooks to communicate with the host application’s infrastructure such as router, store etc.

This interface is stored in one of the common, shared frontend libraries. It allows to define application plugins wherever needed and thus to extend applications at runtime.

Implications for our code base

After we had defined the interface for plugins we needed to implement our plugins by implementing it. We already had a frontend library for every backend microservice – the perfect place to implement our plugins. However, this has an interesting implication: Since federated modules are a feature implemented in webpack, this also meant that we needed a webpack project for federated modules. This also meant we needed a webpack configuration per frontend library project and that it would need to be built and bundled with webpack. This in turn meant we needed to alter our build scripts for frontend libraries to allow building webpack projects, too. Last but not least, the resulting bundle needed to be versioned, archived, and deployed to a server. These were massive changes we had to make to our CI/CD pipeline and they were quite complicated given all the scripts were already used in production.

Module Federation and web components – a match made in heaven

We develop our applications using web components. Since the web standards are very low-level we use a library called Lit to make the development of components simpler. To register new, lazy loaded components with the browser, we call window.customElements.define. Element names must contain a hyphen (so they can be separated from built-in components) and their names must be unique. Otherwise, the browser will throw a critical error. Our components are self-defining, so the call to customElements.define is part of the same component source code file. This has the benefit that the component is automatically registered once the component source code is loaded. Listing 2 shows a very simple HelloWorld component that is self-defining.

import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";

class HelloWorld extends LitElement {
  public render() {
    return html`<p>Hello world!</p>`;
  }
}
window.customElements.define("hello-world", HelloWorld);

Listing 2: A simple component with Lit that is automatically defined once the source file is loaded.

We share our components as npm packages. Our frontend libraries as well as our applications use these packages as dependencies. In a standard webpack setup, webpack makes sure that all source files are only part of the bundle once. This also makes sure that we cannot run into the critical browser error mentioned above with a component being registered twice. Sadly, this is not how things work with module federation.

Sharing dependencies across federated modules

The configuration option shared of the ModuleFederationPlugin allows us to define how dependencies are shared between federated modules. This setting is quite powerful: dependencies can be marked as singletons or the required version number for a package can be specified. Module federation follows Semantic Versioning similar to npm here. So a version number like ^1.0.0 is valid.

Listing 3 shows a sample configuration for sharing dependencies.

const { ModuleFederationPlugin } = require("webpack").container;
const pluginConfig = {
plugins: [
    new ModuleFederationPlugin({
      name: "admin",
      remotes: {
        lib: "lib@http://localhost/lib/remoteEntry.js",
      },
      shared: {lit: {singleton: true}},
         ]
}

Listing 3: Configuration of a federated module which defines the dependency lit as a singleton.

Beware though: The name of the package as a shared key means that only elements in the package root will be shared. For example the import is shared between federated modules if the shared key is lit: import {LitElement} from “lit“. However, the following import is not shared: import {ref} from “lit/decorators/ref“. Ref is not exported a root package level, so it is not shared. If you want to share every export of a dependency, you have to append the shared key with “/“. For example lit becomes lit/. There are lots of more configuration options for sharing dependencies that are explained in the webpack documentation.

Importing Federated Modules

At this point we have learned to define federated modules and to share dependencies between them, but how do you we consume and use them? Every federated module may define as many federated modules it references in its configuration. This is done through the remotes object. Here the key is the name of the federated module. The name will be used to load exposed elements of the module. The value of the object is a string referencing the absolute path to the federated module and the name given to the federated module by its author. (The name used as object key and the name in the object value should match.) Listing 4 shows a very simple example of a federated module admin referencing another federated module called lib.

const { ModuleFederationPlugin } = require("webpack").container;

// Plugins part of the  webpack configuration

plugins: [
    new ModuleFederationPlugin({
      name: "admin",
      remotes: {
        lib: "lib@https://someserverontheinternet.com/lib/remoteEntry.js",
      },
      shared: {"lit/": {singleton: true}},
    }),
]

Listing 4: Plugins part of the webpack configuration showing a federated module referencing another federated module.

To load an exposed module out of lib inside of admin we can simply import it like this: import { someExport } from “lib/exposedModule”. This is very easy and very clean, because it uses standard JavaScript syntax, we are used to.

Unforseen dificulties

I have to admit that referencing the absolute path to the remoteEntry in the webpack configuration is less than ideal. And it did not work for us! We use staging to deploy our artifacts. This means we need to have the same artifact deployed in different stages. So, we cannot use the absolute URL. Luckily, there is a plugin for that. The external-remotes-plugin allows us to place placeholders in the URL specified in the webpack configuration. We can then at runtime set those placeholders using window.location.origin. This works for us, because we are deploying all JavaScript to the same machine.

Another problem surfaced in our failing CI build. We were using a Content Security Policy that mandated that all assets loaded must be verified by a SHA-384-Hash. For this task we use the plugin webpack-subresource-integrity. It generates the SHA-384-Hashes automatically at build time. However, since we are loading federated modules at runtime, we can no longer determine SHA-384-Hashes for them at build time. After a quick consultation with IT security it was decided to change the Content Security Policy for the central administration application to self. This enforces that scripts can only be loaded from our server.

How we load define and load remotes dynamically

The local test was successful, the CI build is passing. For the final test, I gave my changes to my colleague on the other project to verify my changes. Everything works, which is great. After all, they shouldn’t notice anything, but he does notice something in his developer tools: The application is trying to load the remoteEntry of our federated module, although we never actually import anything from it. It turns out that module federation is not so dynamic after all. This is a problem, because the federated module will never be deployed for the other project.

The solution is to remove all remotes from the webpack configuration. To still load remotes dynamically, we can use the @module-federation/utilities package. It provides a function importRemote which we can use to load an exposed element out of a dynamically loaded remote. This concept is called Dynamic Remote Container. Listing 5 shows how we use importRemote to load a plugin.

import { importRemote } from "@module-federation/utilities";
import { LitElement } from "lit";
import type { Plugin } from "projects/common";


class MyApplication extends LitElement {
  // ...
  protected async setupRemotes() {
if (!this.specialAdminFunctionalityNeeded) {
    return;
}
    const remote = `${window.location.protocol}//${window.location.host}`;
    // load the plugin class
    const pluginClass = (await importRemote({

      url: `${remote}/modules/super-special-admin-module`,
      scope: "super__special_admin_module",
      module: "./features/admin-feature",
})) as () => Plugin;
// create an instance of the plugin
const plugin = new pluginClass();
// call lifecycle hooks on plugin instance
plugin.load();
plugin.init();

  }

  // ...
}

Listing 5: Dynamically loading a remote and plugin if it is necessary.

First, in remote we initialize the remote URL. URLs to federated modules still need to be fully qualified. The parameters of importRemote are the following:

  • URL: the absolute URL to the remoteEntry of the federated module
  • scope: the scope under which we access our federated module. Must be identical to its name.
  • module: the module that should be loaded from the federated module.

The result of our importReport call in Listing 5 is our plugin implementation that contains the project specific administrative functionalities. We create an instance of this class and then we call our various lifecycle methods such as load() on it, to make it usable within our application. Listing 5 is just for illustrative purposes, in the real world application there is more setup needed to make a plugin work within the application. However, this is due to our plugin architecture and not federated modules.

After those changes, I asked my colleague to test again. Fortunatelly, he did not notice anything out of the ordinary anymore. He merged the changes and since that day we have been using federated modules in our central administration application. It was quite a bit of effort to get here, but in production this has been running without issues ever since.

our application is not ready for federated modules

I have mentioned at the beginning of the article that we tried to implement federated modules into an existing application without changing much of the application itself. There are multiple issues which showcase that our application was not necessarily made for federated modules.

My personal favorite is something I discovered during testing: When I navigated to a page rendered by a federated module and then instructed the browser to do a page refresh (for example by pressing F5), the application would route me to the 404 page. The reason for this was timing. The router was started before the federated module was loaded and hence the router did not yet know that the federated module provides such a route. The simple fix was to load all plugins before the router was started.

This is not a perfect fix by any means, but it is also something our application architecture currently does not really account for. Most frameworks such as Angular seem to solve this problem by storing (root) route definitions in the application’s initial bundle. However, this trades performance against flexibility. In our case a little delay was feasible (which also only happens on the initial load of the application, since afterwards the browser caches the assets).

Another interesting observation is that our application is now made up out of thousands of small JavaScript files. It is a testament to the optimizations webpack usually would run on a bundle: making sure that most code is bundled together into small chunks. Due to our dependency sharing hints, webpack can no longer anticipate how a dependency might be used. A dependency might not only be used in the initial application bundle, but also in federated modules down the line. However, this is not a problem. Especially since HTTP/2 is optimized for transferring small, individual files. In return, small files have positive impact on caching. Changes no longer mean entire chunks need to be replaced.

Outlook

Our first project with module federation uses our plugin architecture as typed interface. This works well in our case, but of course it would be really nice to just have type definitions for the federated modules.

This is one of the features provided by Module Federation 2.0. Types are generated and served in realtime at development time. This works recursively, so even if a federated module exposes types of a module it consumes itself. In addition necessary types for dependencies like Lit or React are exported automatically. It is a game changer for module federation.

But that is not all: Module Federation 2.0 brings even more to the table. The module federation runtime code has been extracted from webpack and merged into a new runtime library. This makes module federation bundler independent. It allows support for new bundlers like rspack or Vite. In addition, federated modules can now be consumed with any bundler, no matter with which bundler they were built. Moreover, the new runtime library provides more flexibility when loading and initializing federated modules. We used importRemote to dynamically load a federated module at run time, but the new runtime library greatly simplifies this.

In addition the new release introduced developer tools for Chrome based browsers. This plugin provides information on federated modules at development time such as dependencies and exposed modules. Lastly, it provides hot reloading for federated modules.

Module Federation 2.0 boosts significant upgrades compared to the initial webpack 5 implementation. This is especially true for the developer experience. Our own experiences using it in production as well as the team’s continued commitment to evolve module federation show that it has become a fantastic option to implement micro frontends.

Total
0
Shares
Previous Post

From Breaches to Blackouts: The Human Consequences of Software Supply Chain Attacks

Next Post

Exploring XDEV SSE: Enhancing Spring Security for Modern Applications

Related Posts