DEV Community

Cover image for Micro Frontend Discovery - The Driver for Scalability
Florian Rappl
Florian Rappl

Posted on

Micro Frontend Discovery - The Driver for Scalability

Cover photo by Sigmund on Unsplash

In the past six years I've been a part of many projects in the area of micro frontends. While they used a variety of technologies they all had one thing in common: they required a flexible architecture to allow new teams to be on-boarded quickly and existing teams to be full autonomous. This implies that updates are just working and that teams can also disable or hide their micro frontends.

In all those cases a micro frontend discovery service (or something similar, like an adjustable micro frontend configuration file) was the answer to the demands. A micro frontend discovery service is the single-source of truth for an application using micro frontends. It knows what micro frontends are there and how they can be used.

The idea is basically the frontend equivalent of a service registry pattern as illustrated below:

The service registry pattern

How It Works

A micro frontend discovery service is essentially a registry containing information about available micro frontends. In case of, e.g., Piral the information about a micro frontend consists of

  • it's name and version
  • some additional meta data like its author and description
  • an URL to a (JS) file acting as entry or mount point for the micro frontend
  • the dependencies (their names, versions, and URLs) shared from the micro frontend

As an example, calling the Piral Cloud Feed service, which is a micro frontend discovery service, we could get the following response:

{
    "items": [
        {
            "name": "@piral/pilet-feed-dependencies",
            "version": "0.14.0",
            "description": "Piral Cloud Pilets: Feed Dependencies",
            "author": {
                "name": "smapiot",
                "email": ""
            },
            "dependencies": {
                "react-flow-renderer@10.3.17": "https://assets.piral.cloud/cloud/@piral/pilet-feed-dependencies/0.14.0/react-flow-renderer.js"
            },
            "requireRef": "webpackChunkpr_piralpiletfeeddependencies",
            "link": "https://assets.piral.cloud/cloud/@piral/pilet-feed-dependencies/0.14.0/index.js",
            "spec": "v2"
        },
        {
            "name": "@piral/pilet-feed-rules",
            "version": "0.14.0",
            "description": "Piral Cloud Pilets: Feature Flags and Rules",
            "author": {
                "name": "smapiot",
                "email": ""
            },
            "dependencies": {
                "ajv@8.6.3": "https://assets.piral.cloud/cloud/@piral/pilet-feed-rules/0.14.0/ajv.js",
                "jsoneditor@9.5.6": "https://assets.piral.cloud/cloud/@piral/pilet-feed-rules/0.14.0/jsoneditor.js"
            },
            "requireRef": "webpackChunkpr_piralpiletfeedrules",
            "link": "https://assets.piral.cloud/cloud/@piral/pilet-feed-rules/0.14.0/index.js",
            "spec": "v2"
        },
        {
            "name": "@piral/pilet-feed-statistics",
            "version": "0.14.0",
            "description": "Piral Cloud Pilets: Statistics",
            "author": {
                "name": "smapiot",
                "email": ""
            },
            "dependencies": {
                "chart.js@3.6.0": "https://assets.piral.cloud/cloud/@piral/pilet-feed-statistics/0.14.0/chart-js.js",
                "react-chartjs-2@3.3.0": "https://assets.piral.cloud/cloud/@piral/pilet-feed-statistics/0.14.0/react-chartjs-2.js"
            },
            "requireRef": "webpackChunkpr_piralpiletfeedstatistics",
            "link": "https://assets.piral.cloud/cloud/@piral/pilet-feed-statistics/0.14.0/index.js",
            "spec": "v2"
        }
    ],
    "feed": "cloud"
}
Enter fullscreen mode Exit fullscreen mode

So all the micro frontends are listed in an array in the items property.

Sure, the example above is not the only representation. Actually, some frameworks can up with their own notation or leave that up to an application to decide. Alternatively, there is also a proposed standard for this.

With a different representation (e.g., using the proposed standard) we'd get the following response from the service:

{
    "schema": "https://mfewg.org/schema/v1-pre.json",
    "microFrontends": {
        "@piral/pilet-feed-dependencies": [
            {
                "url": "https://assets.piral.cloud/cloud/@piral/pilet-feed-dependencies/0.14.0/index.js",
                "metadata": {
                    "version": "0.14.0"
                },
                "extras": {
                    "pilet": {
                        "spec": "v2",
                        "requireRef": "webpackChunkpr_piralpiletfeeddependencies"
                    },
                    "dependencies": {
                        "react-flow-renderer@10.3.17": "https://assets.piral.cloud/cloud/@piral/pilet-feed-dependencies/0.14.0/react-flow-renderer.js"
                    }
                }
            }
        ],
        "@piral/pilet-feed-rules": [
            {
                "url": "https://assets.piral.cloud/cloud/@piral/pilet-feed-rules/0.14.0/index.js",
                "metadata": {
                    "version": "0.14.0"
                },
                "extras": {
                    "pilet": {
                        "spec": "v2",
                        "requireRef": "webpackChunkpr_piralpiletfeedrules"
                    },
                    "dependencies": {
                        "ajv@8.6.3": "https://assets.piral.cloud/cloud/@piral/pilet-feed-rules/0.14.0/ajv.js",
                        "jsoneditor@9.5.6": "https://assets.piral.cloud/cloud/@piral/pilet-feed-rules/0.14.0/jsoneditor.js"
                    }
                }
            }
        ],
        "@piral/pilet-statistics": [
            {
                "url": "https://assets.piral.cloud/cloud/@piral/pilet-statistics/0.14.0/index.js",
                "metadata": {
                    "version": "0.14.0"
                },
                "extras": {
                    "pilet": {
                        "spec": "v2",
                        "requireRef": "webpackChunkpr_piralpiletstatistics"
                    },
                    "dependencies": {}
                }
            }
        ]
    }
}
Enter fullscreen mode Exit fullscreen mode

In any case, as a consumer of micro frontends this is enough to know about a discovery service; it provides an endpoint that can be used for getting a list of available micro frontends. From a producer perspective there is a bit more to know though...

A micro frontend registry provides the ability to publish micro frontends. This means that either teams or single micro frontends obtain a publish token, i.e., a way for producers to authenticate their request for uploading assets and telling the micro frontend discovery service about these assets.

While a micro frontend discovery service can (or should) always point either the latest or some other selected version of a micro frontend, their could be dynamic rules within the micro frontend discovery service to select some different version. In that regard, a discovery service is a bit like a domain name service. While DNS knows IPs for a certain domain, a micro frontend discovery service knows the URLs of micro frontends for a certain configuration.

How does this enhance development scalability?

Development Scalability

In order to scale development as desired (after all micro frontends are all about development scalability) a couple of points need to be respected:

  • new teams should be able to work in the way they want
  • teams should be full owner of their micro frontends; determining when and how to ship updates
  • when a team decides to disable a micro frontend the application should continue to work
  • when a team releases a new micro frontend the application should not require an update
  • rollbacks of any micro frontend should not require rollbacks of some other micro frontends or the application

All in all the crucial point is to make teams fully independent. If the bullet points above are not fulfilled this is not the case. Worse, you might have a hidden monolith, which is the worst of both worlds (complexity of a distributed system coupled with the alignment needs / cognitive load of a monolith).

Quite often the hidden monolith starts with strong coupling. Once you see direct imports from a certain micro frontend in the application or another micro frontend you know that two pieces that are supposed to be deployed and managed independently form a direct relation. Instead, by going to a micro frontend discovery service you will not see the following relationship:

Strong coupling at work

Instead, think of a discovery service as acting as a kind of inversion of control container. Thus you'll need a way for dependency injection to work. This is, how it would look with a component registry:

The inversion of control principle on a service registry

This does not only solve the bullet points above, it also deals with common challenges such as mitigation of deployment risks and the provisioning of fallbacks if needed (e.g., the discovery service could provide a special build of a micro frontend to run on mobile devices - if the target is a mobile device).

Building Composable Applications

The problem is that some micro frontend frameworks and solutions try to split the UI visually. However, in reality, you will never split your frontend into parts like "navigation", "header", "content", and "footer". Why is that?

A real application is composed of different parts that come from different subdomains. These subdomains come together to form the full application domain. While these sub-domains can be fully separated nicely on paper, they usually appear to the end user within the same layout elements.

Think of something like a web shop. If you have one subdomain for the product details and another subdomain handling previous orders, then you wouldn't want to only see meaningless IDs of products in your order history as a user. Instead, you'd expect that at least the product name and some details are shown in the order history. So, these subdomains interleave visually towards the end user.

Likewise, practically almost every subdomain has something to contribute to shared UI layout elements, such as a navigation, header, or footer. Therefore, having micro frontends that exclusively deal with a navigation area does not make much sense in practice because this micro frontend will receive a lot of requests from other teams — and become a bottleneck. Doing that will result in a hidden monolith.

Now, somebody may argue that not having the navigation in a micro frontend would result in the same demand on changes, but this time on the app shell owner. This would be even worse.

So what is the solution then? Clearly, we need to decouple these things. So instead of using something like:

import MyMenuItem1 from 'my-micro-frontend1';
import MyMenuItem1 from 'my-micro-frontend2';
import MyMenuItemN from 'my-micro-frontendN';

const MyMenu = () => (
  <>
    <MyMenuItem1 />
    <MyMenuItem2 />
    <MyMenuItemN />
  </>
);
Enter fullscreen mode Exit fullscreen mode

We need to register each of the necessary parts, such as the navigation items from the micro frontends themselves. This way, we could end up with a structure such as:

const MyMenu = () => {
  const items = useRegisteredMenuItems();

  return (
    <>
      {items.map(({ id, Component }) => <Component key={id} />)}
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

To avoid needing to know the names and locations of a micro frontend, a kind of discovery is needed. Having the composability in mind we can also make other cases become an easy reality.

For instance, blue green deployments are easily doable. A blue green deployment is an application release model that gradually transfers user traffic from a previous version of a micro frontend (i.e., part of your application) to a new release (usually a patch or feature release, not a complete re-work) within the same environment:

The blue green deployment pattern

Also canary release are easily possible. A canary release updates a micro frontend for a small part of the users first, so they may test it and provide feedback. Once the change is accepted, the updated version is rolled out to the rest of the users:

The canary release deployment pattern

The difference in both models is how the percentage of users on a newer version is set. While blue green has an implied incremental transfer (eventually reaching 100%), the canary release model works on specified/fixed user groups (e.g., beta testers) and manually changes percentage (eventually also settled at 100%, however, this is configured manually).

Now that we know what we need to scale, it's time to start implementing. Luckily, there is a framework that gives us already a head start on this: Piral. What makes this option appealing is that Piral fully embraces a micro frontend discovery service. In fact, Piral provides a free community service that allows us to have a discovery service for publishing.

Example Setup

To fully showcase how a micro frontend discovery service works we can start without any micro frontend framework. Instead, we'll build a solution using plain JavaScript (ESM) modules. No bundler, no magic - just micro frontend discovery with DOM capabilities.

Let's start a new repo for our application - bringing the micro frontends together.

# create a new directory
mkdir my-app
# switch to directory
cd my-app
# initialize npm project
npm init -y
# install dependencies
npm i http-server --save-dev
Enter fullscreen mode Exit fullscreen mode

Let's create a new directory (src) and start a web server exposing the directory:

# create new directory
mkdir src
# start server
npx http-server src --port 8080
Enter fullscreen mode Exit fullscreen mode

Let's add some HTML and a bit of JavaScript. We start with the HTML:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Tractor Store</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <link href="./style.css" rel="stylesheet">
  </head>
  <body>
    <mf-component name="home"></mf-component>
    <script src="./app.js" type="module"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Here, we create a basic example of an app shell that uses an orchestration script ("app.js"). The main content is supposed to come from one micro frontend, which registers a component named home. Instead of directly calling this via a web component (e.g., <mf-a-home></mf-a-home>) we use the generic wrapper component. Now, this generic wrapper follows the extension component strategy outlined in my article about cross framework components.

The orchestration script is therefore no surprise (this is, of course, just a simple variation for the article):

const componentRegistry = {};

class MfComponent extends HTMLElement {
  // see below
}

customElements.define("mf-component", MfComponent);

window.registerComponent = (name, component) => {
  const components = componentRegistry[name] || [];

  components.push(component);

  componentRegistry[name] = components;

  window.dispatchEvent(
    new CustomEvent("component-changed", { detail: { name, components } })
  );
};
Enter fullscreen mode Exit fullscreen mode

For the wrapper component mentioned above we can come up with the following simple variant:

class MfComponent extends HTMLElement {
  // store for data to forward as attributes
  _data = {};

  constructor() {
    super();
    this.data = this.getAttribute("data");
  }

  // handler to notify / re-render when registered components change
  handler = (ev) => {
    const name = this.getAttribute("name");

    if (ev.detail.name === name) {
      this.render(ev.detail.components);
    }
  };

  get data() {
    return this._data;
  }

  set data(value) {
    if (typeof value === "string") {
      // handle setting directly
      value = decodeURIComponent(value)
        .split("&")
        .reduce((obj, item) => {
          const [name, ...rest] = item.split("=");
          obj[name] = rest.join("=");
          return obj;
        }, {});
    }

    if (typeof value === "object") {
      this._data = value || {};
    }

    this.render();
  }

  static get observedAttributes() {
    // we want to be notified when the name and data attribute change
    return ["name", "data"];
  }

  render(components = []) {
    // the rendering logic; we only need to create new components (there cannot be changes to existing ones in this model)
    const newComponents = components.slice(this.children.length);

    newComponents.forEach((componentName) => {
      const element = document.createElement(componentName);
      this.appendChild(element);
    });

    // we always set the attributes of all children - but they might not have changed anyway
    Array.from(this.children).forEach((child) => {
      Object.entries(this._data).forEach(([name, value]) => {
        child.setAttribute(name, value);
      });
    });
  }

  // here we make our first render and couple to the events
  connectedCallback() {
    const name = this.getAttribute("name");
    const components = componentRegistry[name] || [];

    this.render(components);
    window.addEventListener("component-changed", this.handler);
  }

  // here we destroy the rendering and decouple from the events
  disconnectedCallback() {
    this.innerHTML = "";
    window.removeEventListener("component-changed", this.handler);
  }

  attributeChangedCallback(name, oldVal, newVal) {
    if (oldVal !== newVal) {
      if (name === "name") {
        // just restart
        this.disconnectedCallback();
        this.connectedCallback();
      } else if (name === "data") {
        // just set the data via the string setter
        this.data = newVal;
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Going to the website will just show nothing. This is good. So far it should be empty - but importantly, it should show no error in the console. All is working to this point.

Now let's add the first micro frontend to show the page.

# create a new directory
mkdir mf-red
# switch to directory
cd mf-red
# initialize npm project
npm init -y
# install dependencies
npm i http-server --save-dev
Enter fullscreen mode Exit fullscreen mode

Let's create a new directory (src) and start a web server exposing the directory:

# create new directory
mkdir src
# start server
npx http-server src --port 8081 --cors
Enter fullscreen mode Exit fullscreen mode

Let's add some code. The main part will be a file called index.js which aggregates all the components from the micro frontend. It looks like this:

import './product-page.js';

window.registerComponent('home', 'product-page');
Enter fullscreen mode Exit fullscreen mode

Now for the product-page web component the module looks as follows:

// dynamic inclusion of a stylesheet
const link = document.head.appendChild(document.createElement("link"));
link.href = getUrl("product-page.css");
link.rel = "stylesheet";

function getUrl(path) {
  return new URL(path, import.meta.url).href;
}

// some example static data
const product = {
  name: "Tractor",
  variants: [
    {
      sku: "porsche",
      color: "red",
      name: "Porsche-Diesel Master 419",
      image: getUrl("images/tractor-red.jpg"),
      thumb: getUrl("images/tractor-red-thumb.jpg"),
      price: "66,00 €",
    },
    // ...
  ],
};

// some rendering helpers
function renderOptions(sku) {
  return product.variants
    .map(
      (variant) => `
        <button class="${
          sku === variant.sku ? "active" : ""
        }" type="button" data-sku="${variant.sku}">
          <img src="${variant.thumb}" alt="${variant.name}" />
        </button>
      `
    )
    .join("");
}

function getCurrent(sku) {
  return product.variants.find((v) => v.sku === sku) || product.variants[0];
}

function renderImage(current) {
  return `
    <div>
      <img src="${current.image}" alt="${current.name}" />
    </div>
  `;
}

function renderName(current) {
  return `
    ${product.name} <small>${current.name}</small>
  `;
}

// the web component for the product page
class ProductPage extends HTMLElement {
  constructor() {
    super();
  }

  connectedCallback() {
    const sku = this.getAttribute("sku") || "porsche";
    const current = getCurrent(sku);

    // importantly, in the rendered code of this component we refer to components
    // from other micro frontends again using the "mf-component" wrapper web component
    this.innerHTML = `
<h1 id="store">The Model Store</h1>
<mf-component name="basket" data="sku%3D${sku}" class="blue-basket" id="basket"></mf-component>
<div id="image">
  ${renderImage(current)}
</div>
<h2 id="name">
  ${renderName(current)}
</h2>
<div id="options">
  ${renderOptions(current.sku)}
</div>
<mf-component name="buy" data="sku%3D${sku}" class="blue-buy" id="buy"></mf-component>
<mf-component name="recommendations" data="sku%3D${sku}" class="green-recos" id="reco"></mf-component>
    `;

    this.querySelectorAll("#options button").forEach((button) => {
      button.addEventListener("click", () => {
        // change our own attribute, which will propagate to child elements
        this.setAttribute("sku", button.dataset.sku);
      });
    });
  }

  static get observedAttributes() {
    return ["sku"];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (this.isConnected && name === "sku" && oldValue !== newValue) {
      const current = getCurrent(newValue);
      const newData = { sku: newValue };
      this.querySelector("#basket").data = newData;
      this.querySelector("#buy").data = newData;
      this.querySelector("#reco").data = newData;
      this.querySelector("#name").innerHTML = renderName(current);
      this.querySelector("#image").innerHTML = renderImage(current);
      this.querySelectorAll("#options button").forEach((button) => {
        if (button.dataset.sku === newValue) {
          button.classList.add("active");
        } else {
          button.classList.remove("active");
        }
      });
    }
  }
}

customElements.define("product-page", ProductPage);
Enter fullscreen mode Exit fullscreen mode

Doing this should work, but we still get a blank page. Why? Because we have not used any micro frontend discovery yet. So let's start simple. Going back to the app.js of the shell:

const imports = {
  local: 'http://localhost:8081/index.js',
};

Object.values(imports).map((url) => import(url));
Enter fullscreen mode Exit fullscreen mode

There is not a real discovery yet - but if we use the snippet above we at least should see something:

The page with the red micro frontend

Now let's add more component from another micro frontend.

# create a new directory
mkdir mf-blue
# switch to directory
cd mf-blue
# initialize npm project
npm init -y
# install dependencies
npm i http-server --save-dev
Enter fullscreen mode Exit fullscreen mode

Let's create a new directory (src) and start a web server exposing the directory:

# create new directory
mkdir src
# start server
npx http-server src --port 8082 --cors
Enter fullscreen mode Exit fullscreen mode

The entry point (src/index.js) is the same thing - just registering two components this time:

import './basket-info.js';
import './buy-button.js';

window.registerComponent('basket', 'basket-info');
window.registerComponent('buy', 'buy-button');
Enter fullscreen mode Exit fullscreen mode

Now we adjust the imports in app.js:

const imports = {
  red: 'http://localhost:8081/index.js',
  blue: 'http://localhost:8082/index.js',
};
Enter fullscreen mode Exit fullscreen mode

After reloading we see the application using all the available components as they should:

After having the blue micro frontend integrated

Before we add a third, and final, micro frontend, we should go into a micro frontend discovery service. We log into feed.piral.cloud and click on "Create Feed":

Create a feed in the discovery service

Now we should come to a page with the feed details. We copy the URL for the native federation manifest representation (there are other options, too, but this one is the easiest format and thus works for us).

Copy URL of the native federation representation

Now we change our loading logic in app.js to the following (adjust the URL with the copied value from your feed!):

fetch(
  "https://vanilla-mf-discovery-demo.my.piral.cloud/api/v1/native-federation"
).then(async (res) => {
  const data = await res.json();
  await Promise.all(Object.values(data).map((url) => import(url)));
});
Enter fullscreen mode Exit fullscreen mode

We get the micro frontends manifest, inspect it, and iterate over the results. Right now, no micro frontend has been published. How can we do that?

Let's say we want to add the mf-red. We can install the publish-microfrontend package:

npm i publish-microfrontend --save-dev
Enter fullscreen mode Exit fullscreen mode

Now we can use this to publish to the feed:

npx publish-microfrontend --url https://vanilla-mf-discovery-demo.my.piral.cloud/api/v1/native-federation --interactive
Enter fullscreen mode Exit fullscreen mode

The URL we use here is the same URL we refer to in our code. The --interactive flag can be used to publish from our local machines. From a CI/CD pipeline we'd need an API token.

If done correctly we should see one uploaded micro frontend:

Feed service with uploaded micro frontend

We can now do the same for mf-blue and have everything served by the micro frontend discovery service.

Finally, let's add mf-green for the recommendations, publish it and see the result:

Final application

Everything done with loose coupling, served from the micro frontend discovery service. No bundlers, no magic. Just using the DOM and a sound architecture.

You can find the full example on GitHub.

Also for completeness... let's see how the application behaves if we introduce some feature flag to toggle the recommendations based on the used browser:

Toggle recommendation micro frontend

Conclusion

In this article we've learned what a micro frontend discovery service is and why it is necessary to introduce such a service for frontend scalability. Implementing a micro frontend solution without a discovery service is like riding a bike with flat tires. It's possible, but it just won't scale.

Besides the option to make your own implementation there are various open-source projects open for customizations and commercial offerings with batteries included on the market. Which option will you use or do you prefer?

Top comments (5)

Collapse
 
aleksanderlubych profile image
Aleksander Lubych

Great article, thank you for it!

The conclusion that I got from you article is that to avoid the dependencies between the MFE's and to keep the independent release cycles of teams, the centralised solution for Feature Toggles, MFE configuration, Menu Configuration (like you presented) should be introduced - you call it discovery service. This discovery service should be responsible for aggregating the proper information about the MFE's and the solution here is to keep all the needed data in the MFE repository / MFE registry.
Question: what if we have scenerio in which in order to present MFE item in "global scope", lets say in shell's header, we need to combine the Feature Toggle with the MFE configuration and additionally we need to check very domain specific logic calling some API? How would you avoid the coupling in that scenerio?

Collapse
 
florianrappl profile image
Florian Rappl

Well, in the provided scenario there are multiple ways how you can avoid coupling:

  • The order of MFEs is already determined in a way that the registration is deterministic and provides a registration within the header that follows the anticipated order.
  • You use a non-MFE property such as "index" that can be given when a header item is registered (leaving the order decision to the individual MFE)
  • You order the given components by looking at some data, e.g., retrieved also from the discovery service - this data contains the right / currently desired order

The basic idea is to avoid knowledge of a particular MFE / component and its shape, but rather have this decided implicitly / somewhere else.

The last bullet point is the most powerful, but also most complex solution. Our proprietary discovery service ("Piral Cloud Feed Service") has this feature (its called entities).

Collapse
 
aleksanderlubych profile image
Aleksander Lubych

That sounds reasonable!

I think I need dig a bit deeper to understand how you did it in Piral Cloud :)

Collapse
 
kildo162 profile image
KhanhND

That right! I have suggets, you can use cdn in illustrated for direct/optimal media and MF1 vs MF2 can using comm layout (event-driven on MfComponent)

Collapse
 
florianrappl profile image
Florian Rappl

Yes, correct.