Implementing Micro Frontends – What to Look Out For

In the second chapter of our Micro Frontends story, we look at some challenges of implementing MFE architecture and propose solutions for addressing them.

In our previous article, Scale Smarter with Micro Frontends using Nx and Angular, we talked about what Micro Frontends are and what problems they solve. As a quick reminder, there’s an example application repository and a video presentation accompanying the article. 

In this follow-up, we’ll uncover some oddities and talk about certain obstacles that you might come across while implementing a Micro Frontends architecture.

Micro details of Micro Frontends

Even if you use Nx to make working with a monorepo with MFEs easier, there are still many details you will have to figure out on your own. To give you some idea of the challenges you might face, we will go over some of the potential hurdles and propose solutions for overcoming them.

Initialization logic for each individual Micro Frontends app

The challenge

Specific to Angular applications, you can use APP_INITIALIZER to write code that should be executed during app initialization (commonly called bootstrapping), be it synchronous or asynchronous. Angular will wait for all APP_INITIALIZERs to resolve before starting the app. 

This is most commonly used to set up some configuration or fetch some data that should be immediately available for the whole application during start-up. Common use cases are checking whether the app should be loaded in maintenance mode or checking if the user is logged in.

With regular monoliths, there is a single place where you can put all initialization logic – a single entry module or component. With MFEs, that is no longer the case. The host application is started only once, and child applications are loaded as if you were doing regular module lazy-loading. 

If an MFE child application needs to run an initialization logic when it loads, you cannot use APP_INITIALIZER as you would in the host application. You have to find a custom solution.

Our solution

Luckily, Angular’s robust routing features provide guards that allow us to execute code during certain router events. Guards are most commonly used to prevent navigation to a certain route. There is one guard in particular that is of interest to us: CanLoad. This guard will execute only once, during route loading. If the guard passes, the route gets loaded; if not, navigation is canceled, and the target route code does not load.

Because MFE child applications load under specific routes, we can place a CanLoad guard on an MFE app’s main route, on which the remote entry gets loaded.

CanLoad guard can be combined with an extension of ApplicationInitStatus that is used by APP_INITIALIZER itself. This allows you to create something custom that behaves in a similar way to APP_INITIALIZER. Sadly, these parts of Angular are not that well documented, so you will have to go digging into the source code to figure out how to wire everything together. It is also possible that these internals might change a bit in future versions of Angular.

We won’t go into further detail here, but playing around with all this will allow you to create something very similar to the APP_INITIALIZER for your MFE apps’ entry modules. You could perhaps call it MODULE_INITIALIZER and use it like this in your child app’s remote entry:

	@NgModule({
  providers: [
    {
      provide: MODULE_INITIALIZER,
      useFactory: (auth: AuthService) => {
        // initialization logic
      },
      deps: [AuthService],
      multi: true
    }
  ]
})
export class RemoteEntryModule {}

Environment-specific configurations

The challenge

Most apps need some environment-specific configuration for things like API URLs, logging, and so on. Some configurations might be shared between all apps, while some configurations will be app-specific.

Many Angular developers use Angular’s so-called environment files and have separate configuration files for each target environment. We think this is a bad idea, and you can find out why in our documentation pages for our ngx-nuts-and-bolts library, and, more specifically, in the docs for EnvironmentVariablesService

In short, our proposed solution for classic SPAs is to source environment configuration at run-time, instead of build-time. This can be done by fetching the config.json file from assets. Values in this config.json file can be replaced in a post-build and pre-deploy step in your deployment pipeline.

Our solution

What we describe in our ngx-nuts-and-bolts docs can 100% be applied for the host MFE app. There is just one small tweak you have to make in order to make it work with child MFE apps. Each MFE app should have its own config.json, and when the remote entry module gets loaded, it should be fetched, and the child MFE app should get its own instance of EnvironmentVariablesService that will be instantiated with that app’s specific values for environment variables.

Child MFE apps can still inject the parent app’s EnvironmentVariablesService for shared configuration values, but it can also have its own version (provided you separate out dependency injection (DI) tokens for services).

Serving and fetching assets

The challenge

With MFEs, assets can be served from many different places. Some assets might be shared between all apps, like the company logo or font files. Other assets might only be used by one specific app, like translation files. You have to figure out where to store all these assets and how to serve them. You might opt for centralization, or you might want to keep it app-specific; it will depend on the exact use case you have. 

Keep in mind that you can combine centralized and decentralized approaches. For example, use a centralized approach for shared fonts and logos, and keep app-specific images and other assets decentralized.

If you go down the centralization route, it’s relatively easy – all apps need to know about the centralized URL from which assets can be fetched. This URL can be provided by the host application as a shared environment variable that is loaded using the mechanism described in the previous section about Environment-specific configurations.

Despite being more complex, it might be worth it to go down the decentralized route, where each app has its own asset files in its own build.

With a decentralized approach, we help individual teams be as independent as possible during deployments – there is no reliance upon an asset being added to a centralized place.

There is one slight issue, though. All apps are executed from the same root URL, even if various entry modules for child MFE apps are loaded from different URLs. This is one of the main points of MFEs. Assets that are part of a child MFE app’s build artifacts will be served from a different URL, in the same way that the remote entry of a child MFE app gets loaded from a different URL. 

Let’s take a look at an example from our Phlex app that has Movies and Music child apps. If a path to an asset is defined relatively (which it always is in your source code), the path will be resolved relative to the base URL. The result is that ./assets/movies-app-logo.svg will always be resolved to phlex.com/assets/movies-app-logo.svg, never to movies.phlex.com/assets/movies-app-logo.svg. If this file is part of the child MFE app’s build artifacts, phlex.com/assets/movies-app-logo.svg will return 404, while movies.phlex.com/assets/movies-app-logo.svg returns 200.

Our solution

You need a mechanism that lets the child MFE app’s codebase know how to resolve URLs relative to the child app’s file server. Luckily, the solution is there, almost out of the box. In the same way that we want to know the URL of our child app, Webpack’s Module Federation also needs to know where to load remote entry modules from. 

When everything is set up using Nx, URLs for remote entries for each child MFE app are stored in module-federation.manifest.json in the root app. All you need to do is pass this information down to each child app. You can do this via route providers under a DI token (e.g., MFE_CHILD_APP_URL). Child MFE apps can inject the provided URL via the token and prepend all relative URLs so that they get resolved absolutely. Our previous example with ./assets/movies-app-logo.svg would have to be updated to load the file from <MFE_CHILD_APP_URL>/assets/movies-app-logo.svg. The Movies MFE app would receive movide.phlex.com under MFE_CHILD_APP_URL, so the URL ends up as an absolute URL: movies.phlex.com/assets/movies-app-logo.svg.

Providing MFE_CHILD_APP_URL to child MFE apps can be done via a router, as in this example:

	{
  path: 'movies',
  providers: [provideAppUrl('movies')],
  loadChildren: () => loadRemoteModule('movies', './Module').then((m) => m.RemoteEntryModule)
}

The provideAppUrl function reads data from module-federation.manifest.json, which itself could look something like this:

	{
  "movies": "https://movies.phlex.com",
  "music": "https://music.phlex.com"
}

Once the URL is found in this file, the function provides it under the MFE_CHILD_APP_URL DI token, so that it can be injected into the child app’s codebase in order to construct URLs.

Dependency Injection, Providers, and Interceptors

The challenge

In Angular, dependency injection is used extensively, and services must be provided somewhere. Sometimes, it can be hard to figure out if a service should be provided by the host application or by the child application. 

There is also the question of whether it should be a global singleton or whether a new instance should be provided for each MFE app. Good patterns for providing services (e.g., functional providers like provideHttpClient) and good use of things like optional injection flags can go a long way in helping you keep track of things and make debugging easier.

Some things have to be provided globally, like HTTP interceptors. Interceptors cannot be controlled by child applications, and they have to be set by the host application. This is a very similar issue to the initialization issue. It’s made a bit harder because there should ideally be only one instance of the HttpClient. Theoretically, each child MFE app could have its own HttpClient, but that would mean that no interceptors can be inherited from the parent app. It’s certainly doable if you have a good way of providing the same interceptors to all child apps, but it is more prone to causing errors, as you have to make sure all apps include all the necessary providers.

Our solution

To solve the problem, you must rely on the benefit of MFEs and Module Federation, which allows us to have singletons across the host and all child apps.

A good solution is to create a service in the host app that is shared as a singleton with all child apps. This service allows child apps to “register” some handlers in a similar way to regular HTTP interceptor definitions, but it’s not done via DI (because interceptors have to be provided at the time HttpClient is provided). The host app will register a single interceptor, but that interceptor’s implementation will execute all the registered child handlers dynamically. There is even an unexpected added benefit of having control of the order of interceptors, something that is a bit more obscure when defining multiple regular individual interceptors.

The solution integrates with the previously mentioned MODULE_INITIALIZER by implementing a module initializer factory, like so:

	export const configFactory = (
  configService: ConfigService,
  protectedResourceStore: ProtectedResourceStore
) => async () => {
  const { apiUrl } = await configService.loadConfig();
  protectedResourceStore.addProtectedResource(apiUrl);
};

During MFE apps’ module initialization, we will fetch this app’s config.json. The config file includes the apiUrl property, and we can register this URL with ProtectedResourceStore. URLs registered in this store are used by the authentication interceptor in the host application to determine whether an authentication token should be attached or not.

Managing dependencies

The challenge

Some packages in the monorepo will be used by a large number of other packages and applications. Take extra care when making changes to these packages, as you will have to handle breaking changes in many places. Taking a look at Nx’s project graph and checking the list of affected packages can help you figure out what is the scope of your changes. When you make the changes to a shared package, make sure to communicate the change to other teams, ideally via email, with a link to the changelog that includes information on how to handle changes.

Changes to third-party packages can be even more cumbersome if they are used by all the apps. If you update Angular to a new major version, you have to handle breaking changes in all the apps, as the Angular version is shared between all the apps. Although Webpack Module Federation allows mismatched versions, Nx’s MFE configuration does not allow mismatched versions by default, and you probably want to keep it that way.

Our solution

There is really no silver-bullet solution for this, and each time you update dependencies, you might end up with unique challenges. One thing we can recommend is to update third-party packages as often as possible, in order to run into smaller sets of issues each time you do the update. If you wait too long, there will be a large amount of changes, and handling them all at once can be overwhelming. This advice stands for regular non-MFE apps but is even more important for MFEs inside a monorepo because many different apps share the same versions of various packages.

In-sync deployments

The challenge

Although one of the main goals of MFE architecture is to allow independent deployment of individual MFE applications, there are some changes that will require in-sync deployment of all apps. This can happen when updating shared third-party packages like the framework version (e.g., the Angular version) or when changing a package that is used by multiple apps (e.g., a shared utilities library).

Our solution

It’s important to understand what makes a package a shared package. Nx constructs a graph of dependencies between all packages in the monorepo, and if a package is used in the host app and in some child app, it will be considered a shared package. 

Additionally, all third-party packages from package.json are shared by default. You can control this behavior by using functions like applySharedFunction and applyAdditionalShared from @nx/devkit. All shared packages are loaded from the host application via Module Federation. This can be a tricky thing to figure out and easy to miss in crucial moments when you are trying to figure out why a deployed fix is not working.

If you fix something in a shared package that a child app relies on, you have to deploy the fixed version of both the host and the child app, even if the specific fix is irrelevant for the host application’s codebase. If you deploy just the child app, module federation will still load the outdated version of the package from the host app’s build artifacts.

Handling deprecated packages and apps

The challenge

Having many different apps and packages in the monorepo will inevitably result in some apps or packages becoming deprecated and entering a support phase. There might be no (or very little) budget to keep such apps running. If there is a major change in shared libraries (be it first or third party), it might not make business sense to spend time handling the change in an old app that is out of the maintenance budget.

Our solution

If you run into this situation, it might be best to fork the monorepo into a new repository where this major change is not executed and deploy a stand-alone version of the deprecated app. This might mean having to create a one-off host application for a deprecated child MFE app, because you can no longer use the mainline host application.

Once forked, you can leave it like that for as long as necessary. If, one day, you get the budget to continue maintaining the app, you can merge it back into upstream and handle the changes that happened upstream. Because you want to keep this merge door open, it is important to commit all the migrations that happen in the mainline, so that the same migrations can be applied sequentially to the branch being merged back into the upstream. Follow Nx’s guide on updates and handling migrations for more details as you go along. If you want to learn a bit more about forks and upstream merges, check out Atlasian’s tutorial on the topic.

Are Micro Frontends a good choice? You decide.

As always, the devil is in the detail, and you should not take things at face value. At a glance, MFEs seem simple enough, but as we’ve shown in this article, there will be obstacles along the way. We have proposed some solutions in this article, and for more complex problems, we hope we’ve at least pointed you in the right direction.

Armed with all the information from our previous article, which addressed what MFEs are and how they work, you should now be well-equipped to make a good decision on whether or not MFEs are the right choice for you. 

Based on our experience, we believe MFEs are a good fit for larger projects that are worked on by multiple teams, especially in large organizations. Some industries in which MFEs are likely to be useful are IoT, healthcare, education, government, and finance. Consider using Micro Frontends when starting your next project, or when breaking down a monolith, and don’t hesitate to drop us a line if you need consultation.