Image by Arek Socha from Pixabay
In the recent year the term "microfrontends" entered the tech mainstream. Advocates of this pattern claim that microfrontends bring to the frontend the same liberation as microservices did to the backend. In this post I want to shed some light on the topic - having introduced microfrontends for larger applications in the last 3 years.
This article has been originally published at **Bits and Pieces. See blog.bitsrc.io for the original content.
The Promise
Why is there such a hype for microfrontends? Well, in a nutshell the microfrontend architecture offers us a new dimension for composing our applications. Consider the case of a frontend monolith: Having a single team will eventually fail - having multiple teams will lead to massive communication and organizational overhead. If we could break this up into smaller chunks that can be developed and deployed independently, multiple teams would (ideally) not step on each other's toes.
Cutting responsibility can be done in multiple ways. Like for microservice backends the way of cutting the responsibilities is already essential for determining how the teams will be composed. Here it helps to apply ideas from domain-driven design, however, since we talk about frontend the actual user-experience and what we want to deliver (or aggregate) in terms of functionality may influence the split, too.
A popular choice is the creation of autonomous fullstack teams. Each team is responsible for a single microservice and the microfrontend primarily serving that content.
The Analogy
Many microservice backends are not consumed as such. While services may be communicating internally (sometimes directly, but quite often via message brokers or similar technologies) these services are exposed to the outside via a gateway. In the microfrontend architecture the role of a gateway is taken by an application shell. The application shell is the central point from the user consumes the application. While some microfrontend frameworks tend to compose this app shell in the backend, others do this in the frontend.
Depending on the size and quality of the engineering teams different technologies may be used for microservice backends. Nevertheless, infrastructure (and potentially governance) still dictates how the API will look like; what is the way of communication and what patterns must be respected. Simple infrastructure needs such as a status or health check endpoint are usually normalized.
In microfrontends this normalization is mostly done towards the user by providing a standardized user experience. Like for microservices, microfrontends can also work with shared libraries. A popular choice is a pattern library, which should provide a set of components that will then determine the look and behavior of the different frontend parts.
Recently, microfrontend teams have adopted tools like Bit to share their UI components from their own project to a shared library, and even collaborate on components with other teams. This makes it much easier to maintain a consistent UI across microfrontends, without investing time and effort on building and maintaining a UI component library.
Quite often an argument in favor of microservices is the use of different technologies for implementing different services. In practice this does not matter much, because most microservice backends are implemented by businesses that cannot afford fragmenting their technology landscape too much. In other cases, there is still an overhead for doing this - as shared code (common libraries) or practices cannot be simply transferred. As a consequence, while the ability to use different technologies is appealing, it will most often not be used.
Likewise, for microfrontends we may also want to be able to use different technologies, e.g., Angular and React, however, in practice this will not play an important role. There are several reasons why the use of different technologies is even less appealing for microfrontends than it is for microservices.
First, the use of pattern libraries may be almost ruled out. Indeed, while common styling etc. may still work, most of the benefit is not coming from a simple composure of HTML and CSS, but rather of UI logic and behavior. A component implemented in React would need to be reimplemented in Angular and vice versa.
Second, having all these different frameworks at the same time will come with costs in terms of performance. The bundle will be larger and the memory footprint higher. The web app will feel much too heavy.
For microservices the internal communication can be either brokered using e.g. a message bus or via direct calls. In contrast, the direct communication path should be forbidden within a microfrontend architecture. Instead, the loose coupling favors communication that goes via independent brokers, such as an eventing system, or a global state container.
Solution Spectrum
As with microservices we can follow a more pragmatic path or a stricter path regarding the independence of the different microfrontends.
Just enumerating the extremes, I see four potential options for a solution:
- Nothing given - everything stitched together by some conventions or configuration. All microfrontends start in a vacuum.
- Shared libraries. This is like a framework, which then allows the stitching to happen via functions. A boilerplate needs to be used. Design-wise all microfrontends start in a vacuum.
- Shared design. Here a pattern library in form of CSS and a common technology may be given, otherwise all microfrontends start without a boilerplate. The stitching must be done such that a given design fills menus and other parts by some conventions or configurations.
- The Modulith, where we combine shared libraries and a shared design in such a way that everything is done programmatically. Parts of the solution are thus given, while other parts can still be determined by the developer.
This can also be drawn as sketched below.
All quadrants may make sense depending on the problem to solve. In my experience the last solution tends to be ideal for many cases. Examples of this style can be found in larger quantity. More prominent results include the Azure Portal, Azure DevOps, or even applications such as VS Code. However, despite being a great solution depending on the scenario it also comes with some challenges. Most notably, updating the shared dependencies becomes a headache that requires tooling and governance.
Ideal Solution
For the actual implementation of a microfrontend I consider the following principles worth following:
- Serverless-first: As a microfrontend solution should just work, it must be possible to use it without any requirements to the infrastructure.
- Developer-first: A microfrontend should be up and running in seconds to minutes, incl. full IDE support and enhanced debugging experience.
Since usually these things should be modern and highly-interactive, I rate a single-page application desirable; even though the possibility of rendering the whole application server-side should be still possible.
In the end when we consider the Modulith as an ideal solution the spectrum of available (open-source) solutions is - despite being in its infancy - already there and growing. The solution I want to present in this post is called Piral. This is a microfrontend framework based on React that comes with tooling to address all the challenges that may be faced in the process.
The intersection displayed above gives us the following characteristics:
- Business capabilities as modules
- Loose coupling with dynamic loading
- Shared architecture foundation
- Consistent UI & UX
- Development by independent teams
In addition, the two principles mentioned above are followed by Piral by not requiring any backend at all. The application can just be hosted on some static storage, such as GitHub pages, Amazon S3, or an Azure Blob. Additionally, by providing a feature-rich command line tooling great DX is ensured. The framework that is provided by Piral can be described as a way to integrate React components in form of a plugin model.
Microfrontends with Piral
Piral tries to tackle the full development life cycle - split in two halves:
- Tackling the application shell; from a boilerplate template over debugging to building and publishing.
- For a module (called a pilet) - from scaffolding over debugging to building and publishing.
Normally, we would start with the application shell. Once that reached a certain level we would focus on individual modules; with updates to the application shell only being performed to support new capabilities or to make some changes to the overall layout.
An application shell with Piral can look as simple as follows:
import * as React from "react";
import { render } from "react-dom";
import { createInstance, Piral, Dashboard } from "piral";
import { Layout, Loader } from "./layout";
const instance = createInstance({
requestPilets() {
return fetch("https://feed.piral.io/api/v1/pilet/sample")
.then(res => res.json())
.then(res => res.items);
}
});
const app = (
<Piral instance={instance}>
<SetComponent name="LoadingIndicator" component={Loader} />
<SetComponent name="Layout" component={Layout} />
<SetRoute path="/" component={Dashboard} />
</Piral>
);
render(app, document.querySelector("#app"));
Creating a new pilet is simple and straight forward with the Piral CLI. While some scaffolding parts are already pre-determined by Piral, the specific application shell can actually specify what should be done. Additional hooks add another dimension of flexibility.
Scaffolding a new pilet works via the command line. If a command-line survey is preferred we can use the NPM initializer:
npm init pilet
Alternatively, we can use the Piral CLI:
pilet new sample-piral
In the example above the name of the application shell is sample-piral
. Once we are done the debugging process can be started via npm start
.
The actual module has an index.tsx root module that just exports a single function called setup
:
import * as React from "react";
import { PiletApi } from "sample-piral";
export function setup(app: PiletApi) {
app.showNotification("Hello from Piral!");
app.registerTile(() => <div>Welcome to Piral!</div>, {
initialColumns: 2,
initialRows: 2
});
}
All this function (or module) does is to wire up the components / logic to the application shell. A sound microfrontend architecture would not rely on the pilet API beyond the index.tsx file. All wiring logic should be contained in this one module, while other modules are pretty much isolated from Piral.
Later on, Piral will load this module in the beginning. Hence, we should see that a single microfrontend does not grow too big. If it does, lazy loading may help. For this, we use the same technique as in other bundled web applications: we bundle split via import
.
import * as React from "react";
import { PiletApi } from "sample-piral";
const Page = React.lazy(() => import("./Page"));
export function setup(app: PiletApi) {
app.registerPage("/my-demo", Page);
}
React makes sure that the bundle for the Page
component is only loaded when its first needed.
Practical Example
Let's look at a more practical and complete example. One toy project that can be (re)build is the quite known microfrontend shopping demo application. You can find the repo of the recreation on GitHub: https://github.com/FlorianRappl/piral-microfrontend-demo.
The demo consists of an application shell and three pilets:
- Providing a products page using components from other pilets; a shopping cart and more product recommendations
- Sharing a shopping cart component and a buy button component
- Sharing a list of product recommendations
In the screen it looks as follows:
The application shell of this example will be super easy, because in this example the application shell does not come with any layout.
import * as React from "react";
import { render } from "react-dom";
import { Redirect } from "react-router-dom";
import { createPiral, Piral, SetRoute } from "piral";
import { createContainerApi } from "piral-containers";
const piral = createPiral({
requestPilets() {
return fetch("https://feed.piral.io/api/v1/pilet/mife-demo")
.then(res => res.json())
.then(res => res.items);
},
extendApi: [createContainerApi()]
});
const app = (
<Piral instance={piral}>
<SetRedirect from="/" to="/products" />
</Piral>
);
render(app, document.querySelector("#app"));
The only two things special we do here are redirecting on the homepage to the "products" route to display directly the products. The other thing is that we bring in an additional API for the pilets to use - the "container API", which gives pilets the possibility of declaring a global state quite easily.
The state container is then used by some of the pilets, e.g., the shopping cart pilet wires into the application shell as follows:
import * as React from "react";
import { PiletApi } from "app-shell";
import { BuyButton } from "./BuyButton";
import { BasketInfo } from "./BasketInfo";
interface BasketInfoExtension {}
interface BuyButtonExtension {
item: string;
}
export function setup(app: PiletApi) {
const connectBasket = app.createState({
state: {
items: []
},
actions: {
addToCart(dispatch, item: string) {
dispatch(state => ({
...state,
items: [...state.items, item]
}));
}
}
});
app.registerExtension<BuyButtonExtension>(
"buy-button",
connectBasket(({ actions, params }) => (
<BuyButton addToCart={actions.addToCart} item={params.item} />
))
);
app.registerExtension<BasketInfoExtension>(
"basket-info",
connectBasket(({ state }) => <BasketInfo count={state.items.length} />)
);
}
These extensions are used in the products page wired up by the products pilet:
import * as React from "react";
import { PiletApi } from "app-shell";
import { ProductPage } from "./ProductPage";
export function setup(app: PiletApi) {
const { Extension } = app;
const BasketInfo = () => <Extension name="basket-info" />;
const BuyButton = ({ item }) => (
<Extension name="buy-button" params={{ item }} />
);
const Recommendations = ({ item }) => (
<Extension name="recommendations" params={{ item }} />
);
app.registerPage("/products/:name?", ({ history, match }) => (
<ProductPage
name={match.params.name || "porsche"}
history={history}
BasketInfo={BasketInfo}
BuyButton={BuyButton}
Recommendations={Recommendations}
/>
));
}
The Extension
component from the Pilet API can be used to access loosely coupled components provided by other pilets. If these pilets are not loaded then just nothing is rendered.
Conclusion
Microfrontends are not for everyone. However, once the application is large enough or has special extensibility needs it makes sense to think about using microfrontends. While there are many possible solutions the Modulith with a fast deployment time and swift user experience may be the ideal solution.
I think Piral may be interesting due to the enhanced development experience combined with the serverless-first approach.
Top comments (0)