In recent years the term "microfrontends" entered the tech mainstream. While there are many patterns of actually implementing microfrontends we feel that there may be an "ideal" solution out there - a solution that combines the advantages of the monolith with some of the benefits of using isolated modules.
In this post we'll look into a microfrontend solution built on React, which allows unbounded scaling of the development, progressive rollouts, and following a serverless infrastructure. Our solution consists of an app shell and independently developed modules, which are dynamically integrated into the app shell.
The solution that we'll use is called Piral, which is a reference implementation of our modular architecture for frontends. The definition of that frontend architecture is based on real-world experiences we gained out of several customer projects over the last three years.
The Modulith
The great thing about an approach that considers the intersection between monolith and micro app (called a Modulith) is that we can allow things such as
- progressive adoption (for an easy migration path),
- shared libraries (such as a pattern library), or
- an existing layout / application frame.
All these are just possibilities. The downside comes with the responsibilities inherited when adopting of such options, e.g., including shared libraries in the app shell will result in the classic dependency management issues.
How does the modulith relate to a microfronted? Below we see a possible microfrontend design - each service gets an associated microfrontend. Every microfrontend represents an isolated unit, potentially coming with its own pattern library and technology.
In contrast, the Modulith tries to reuse the important parts responsible for UX. As such consistency is key here. Obviously, with this approach some challenges come, too, but the considerations between consistency and redundancy is what makes creating frontend UIs different to backend services.
The image above shows the additions of the modulith, which gives a bounding box concerned with the overarching responsibilities. The entry point is the application shell.
An Application Shell
Usually, the creation of a new application that leverages microfrontends starts with the scaffolding of an app shell. The app shell contains the shared layout, some core business functionality (if any), and the share dependencies. The app shell is also responsible for setting up the basic rules that need to be followed by all modules, which are called pilets in the context of Piral.
In the simplest example an app shell could look as follows:
import * as React from "react";
import { render } from "react-dom";
import { Redirect } from "react-router-dom";
import { createPiral, Piral, SetRoute } from "piral";
const piral = createPiral({
requestPilets() {
return fetch("https://feed.piral.io/api/v1/pilet/mife-demo")
.then(res => res.json())
.then(res => res.items);
}
});
const app = <Piral instance={piral} />;
render(app, document.querySelector("#app"));
This creates a blank app shell, which already allows having different pages and fragments being stitched together.
Great, so how should we deploy this application? There are two things to do here:
- Build (i.e., bundle) the application and push it to some storage.
- Package the sources and push it to a (private) registry. Alternatively: Share the tarball.
The first step ensures that our application can be reached from the Internet. Great! The second step requires some explanation. One of the problems when dealing with microfrontends is that "how do I develop this stuff"? After all, we only have a module of a larger application in our hands. What if we want to look into interactions between these modules? What if we want to see if our style fits into the larger UX?
The answer to all these questions can be found in the development of a native mobile app: Here we also did not develop in a vacuum. Instead, we had an emulator - a piece of software that looked and behaved just like the system we will deploy to. In microfrontend terms we require the app shell to be there for our development process. But how do we get this? Especially, since we also want to keep developing while being offline. As a consequence, we need a way of sharing the app shell to allow an "emulation" and thus support a swift development process.
Anatomy of a Pilet
While the app shell is definitely important, even more important are all the pilets. Most of the time a Piral-based app shell is only in maintenance mode - all the features are developed independently in form of the pilets.
A pilet is just an NPM package that contains a JavaScript file ("main bundle", produced as an UMD). Furthermore, it may contain other assets (e.g., CSS files, images, ...), as well as more JavaScript files ("side bundles").
From a coding perspective a pilet has only one constraint - that it exports a function called setup
. This function receives the API that allows the developer of the pilet to decide which technologies and functions to utilize within the module.
In short, a pilet may be as simple as:
import * as React from "react";
import { PiletApi } from "app-shell";
export function setup(app: PiletApi) {
app.registerPage("/sample", () => (
<div>
<h1>Hello World!</h1>
<p>Welcome to your personal pilet :-).</p>
</div>
));
}
Naturally, pilets should be as lazy as possible. Thus, any larger (or even part that may not required immediately) should only be loaded when needed.
A simple transformation with methods from our standard tool belt can help:
// index.tsx
import * as React from "react";
import { PiletApi } from "app-shell";
const Page = React.lazy(() => import("./Page"));
export function setup(app: PiletApi) {
app.registerPage("/sample", Page);
}
// Page.tsx
import * as React from "react";
export default () => (
<div>
<h1>Hello World!</h1>
<p>Welcome to your personal pilet :-).</p>
</div>
);
All that works just fine with Piral. It's important to keep in mind that in the (granted, quite simple) codebase above, Piral is only mentioned in the root module. This is a good and desired design. As the author of a pilet one may pick how deep Piral should be integrated. Our recommendation is to only use the root module for this integration.
So far so good, but how is the pilet then brought into our (real, i.e., deployed) app shell? The answer is the feed service. We've already seen that our app shell fetched some data from "https://feed.piral.io/api/v1/pilet/mife-demo". The response to this request contains some metadata that allows Piral to retrieve the different pilets by receiving a link to their main bundle.
Everyone is free to develop or roll out a custom-made feed service. By providing the specification and an Express-based Node.js sample we think the foundation is there. Additionally, we host a flexible feed service online. This one includes everything to get started efficiently.
The Piral CLI
All the magic that happened so far can be found within the Piral CLI. The Piral CLI is a simple command line tool that takes care of:
- scaffolding (with
piral new
for a new app shell orpilet new
for a new pilet) - debugging (with
piral debug
to debug an app shell; for pilets usepilet debug
) - building (with
piral build
orpilet build
) - publishing a pilet (
pilet publish
)
In the whole high-level architecture the place of the Piral CLI is right between the developer and the feed service. As already remarked, the feed service is the only required backend component in this architecture. It decouples the application shell from the specific modules and allows more advanced use cases such as user-specific delivery of modules.
Internally, the Piral CLI uses Parcel. As a result, all plugins for Parcel (as well as their configuration - if required) just work.
The Piral CLI also supports plugins on its own.
Further Reading
There are already some articles out about Piral.
Furthermore, the documentation may also be helpful. It contains insights on all the types, a tutorial storyline, and a list of the available extensions.
Get Piral!
If you are thinking about adopting microfrontends, Piral might be the choice for you. It requires the least infrastructure giving you the most value for your users. Piral was designed to provide a first-class development experience, including the possibility of a progressive adoption (i.e., starting with an existing application - bringing in the capability of loading pilets before actually developing pilets).
With the optional inclusion of "converters" (e.g., Angular, Vue) it is possible to support multi-technologies or migrations of legacy technology. The current list of all official extensions (incl. converters) is accessible on our docs page.
We would love to get your feedback! ??
Share the link, star the project ?? - much appreciated ??!