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:
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"
}
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": {}
}
}
]
}
}
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:
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:
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 />
</>
);
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} />)}
</>
);
};
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:
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 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
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
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>
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 } })
);
};
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;
}
}
}
}
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
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
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');
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);
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));
There is not a real discovery yet - but if we use the snippet above we at least should see something:
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
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
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');
Now we adjust the imports in app.js:
const imports = {
red: 'http://localhost:8081/index.js',
blue: 'http://localhost:8082/index.js',
};
After reloading we see the application using all the available components as they should:
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":
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).
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)));
});
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
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
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:
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:
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:
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)
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?
Well, in the provided scenario there are multiple ways how you can avoid coupling:
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).
That sounds reasonable!
I think I need dig a bit deeper to understand how you did it in Piral Cloud :)
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)
Yes, correct.