Module Federation in Production: architecture, development workflow, and deployments

Module Federation in Production: architecture, development workflow, and deployments

Introduction

Micro-frontends are an architectural pattern that breaks a web application into smaller, loosely coupled, and independently deployable parts. Each part of the application is owned by a separate team and can be developed and deployed independently, allowing teams to work more efficiently and without interfering with each other

This article focuses on optimising developer experience in a micro-frontend built with Module Federation while achieving team independence in deployments. A general understanding of Module Federation is assumed; if you need an introduction, you can watch my talk on Module Federation and how it works here.

Why Module Federation

Module federation is a feature of webpack 5 that enables developers to share code between multiple webpack builds, allowing them to communicate with each other and build distributed applications with greater scalability and flexibility.

Module Federation has emerged as a powerful tool for enterprise-scale applications. It is easy to configure and allows code to scale horizontally rather than vertically, by distributing the code between different builds

Building a shell based architecture

Module Federation is un-opinionated but my suggestion is to use a shell-based architecture, where the shell is a lightweight base that handles routing between micro-apps, initialisation of shared data stores, and lazy loading of each app. The shell is initialised first, and then, based on the route, it lazy loads the apps to be rendered.

Diagram showing a shell based architecture with three micro apps

Ideally, the shell should not contain any business logic and should only bring in the micro-apps that perform the actual work. However, routing between apps is one exception where some business logic may be required.

The shell should not use any front-end framework, since the micro-apps themselves could be built with different frameworks. Additionally, any code added to the shell should be independent of any specific framework.

To keep the shell lightweight, it's important to minimise the amount of code added to it. Adding code that is not required by all apps will unnecessarily increase the bundle size of the shell. Therefore, it's best to add only the necessary code and avoid adding anything that is not essential for its functionality.

You could store common code, such as component libraries that are shared between all apps, in the shell. Alternatively, your micro-apps could be either "apps" or "packages". Packages are pieces of code that are imported by apps but can be deployed independently of them. The component library/ design system can be a “package” which is independent from other apps and can be also deployed independently.

Micro apps should avoid direct communication with each other to prevent creating an implicit dependency between them. When one app changes its stored data, other apps that depend on it are affected, which adds undesired complexity.

As an extension of above, one could build an architecture where there are multiple shells, and each shell is its own product. The shells load micro-apps which are required by the product

Optimising for developer experience

A great developer experience in a micro-frontend would involve the ability to build an app in isolation. Unfortunately, this is not always possible due to the need for libraries or shared code that is imported from the shell at runtime. This can cause problems when the development server for the shell breaks because it tries to load code from another app whose development server is not started.

To address this issue, you may have to spin up multiple development servers for different apps, even if you only want to work on one. While tools like Nx and Turborepo can help reduce this pain by allowing developers to share build cache between machines using the cloud, it would be even better if you didn't have to run those development servers at all. In the following section, I will explain how to achieve this.

The goal is to only start the development server for the app you are working on. Other code should always be loaded from either a CDN or an already running server elsewhere. This ensures that you always get the latest code for apps developed by other teams. Additionally, which apps to be loaded from the local dev server and which ones to be loaded from the CDN should be granular and easy to switch between, like the press of a button.

This is the system we'll be looking at: you can switch between a local development server and an already-built bundle of the micro-app with just the press of a button.

In this example, we will use a client-rendered app. We will primarily use two tools: a browser plugin called Redirector and a CDN like AWS's CloudFront. If you prefer serving static files or if the app is server-rendered, you can even use an NGINX reverse-proxy. However, for this example, we will only be using CloudFront.

Architecture diagram showing use of redirector extension

Switch between pre-built bundles and the development server using the Redirector extension

Let's discuss the switch that enables us to granularly switch between a local build and a cloud build. This switch is essentially the Redirector browser extension.

The advantage of using this browser plugin instead of passing arguments on the command line interface (CLI) is that you do not need to restart the webpack development server.

The way the redirector extension works is by using a regex pattern. It examines all outgoing requests and redirects any request whose path matches the regex pattern to another URL specified by you. This URL can be the path of the local development server.

The extension allows us to add multiple regex patterns, each representing the URL of a micro-app. These can be easily enabled or disabled with the click of a button.

Since we expect to be building many micro apps, it is important to establish a naming scheme. A possible scheme is to include a path in the middle, such as /federated/. This way, each app can have a unique URL like /federated/my-micro-app/.

So lets say the CDN is at the URL https://my-federated-app.com and the remote entry file for the micro-app is at https://my-federated-app.com/federated/my-micro-app/remoteEntry.js. Then, if I want to work on this app using a local development server, I can redirect any request containing the pattern /federated/my-micro-app/ to http://localhost:3000 , where the development server is running. Here’s an example of the configuration that does the above job:

Example of redirector configuration

This way, I can have multiple apps running locally, each development server running on a different port. I can switch between their local and cloud builds using the Redirector browser extension by enabling or disabling this rule

Switch for my-micro-app

The CloudFront configuration

The configuration will be similar to that of the Redirector plugin. The idea is to use CloudFront's “behaviors” option to route requests appropriately to the folder in S3 where the app is stored.

For example, let's consider the following structure for storing builds in S3 for all of your apps.

|-- shell
|---- remoteEntry.js
|---- (other js assets)
|-- my-micro-app
|---- remoteEntry.js
|---- (other js assets)

Next, you can configure the default behavior of CloudFront to fetch files from the shell folder.

Diagram showing redirection based on path in CloudFront

If your domain is my-federated-app.com, by default, all requests will go to the shell folder. In the CDN configuration, you can add a behavior to redirect any request that contains the path /federated/my-micro-app/ to the corresponding “my-micro-app” folder using the pattern federated/my-micro-app/*. You will need to configure this for each micro app that you build.

Note: An important thing to keep in mind for client-side rendered apps is cache busting of remote entry files. Since you can't use content hash to name these files, they will get cached at the CDN and in the user's browser, which can cause problems when builds are out of sync. One way to fix this issue is to invalidate the CDN cache after every app deployment.

Note 2: Alternatively, you can create a "federated" folder inside the "shell" folder. However, this is not recommended as a simple mistake could override all the files inside the "federated" folder or even delete that folder.

Note 3: Depending on the size of your team and organisation, you could use the same CloudFront configuration to serve apps from different buckets. Each micro-app would have its own S3 bucket, allowing for even more separation. For even larger scale, each app could have its own CloudFront CDN with a dedicated sub-domain to serve assets from.

Conclusion

In conclusion, Module Federation is a powerful tool for building large-scale micro-frontend architectures. By breaking up a web application into smaller, independently deployable micro-apps, teams can work more efficiently and without interfering with each other. Using a shell-based architecture and minimising direct communication between micro-apps can further optimise the scalability and flexibility of these architectures. Finally, optimising for developer experience can be achieved by using tools such as the Redirector browser extension and AWS's CloudFront CDN.

This is the first article in a new series I am writing on micro-frontends with Module Federation. In the next article, I will discuss state management strategies to be used in a micro-frontend. If you'd like to be notified when I next publish an article, you can subscribe to the newsletter at burhanuday.com