As business logic increasingly moves to the front-end, more and more technologies are moving with the trend. In this article, we’ll explore how micro-service architecture – typically associated with backend development – is now available for front-end developers. I’ll explain the theory of micro frontends through a real-world example. This way, I can easily flag the advantages and common issues faced by you guys: the early adopters.
What are Micro-Services?
Micro-service architecture reduces complex applications into single-purpose components. This is a more efficient approach because components are fault isolated: if one breaks, the rest of the app continues to work.
Each component can be built by a small, cross-functional team, enabling that team to choose the ideal technology for the job while also deploying independently, at their own pace.
How does this all apply to front-end development?
I’ll attempt to answer this question within the context of an app I built using the micro-frontend architecture. MiSongs, the app in question, lets users browse through different artists and musical genres before adding their favorite songs to a playlist. Hopefully, MiSongs will let you understand the concepts of micro-frontends, learn the inner workings of this architecture and also serve as a quick reference for your next project.
Let’s start with a look at the MiSongs wireframe:
As you can see, the app is divided into 3 core capabilities:
- List artists by genre
- List songs by artist
- Add songs to a playlist.
I could have opted to build this app as a feature-rich single page app in the frontend, using a set of micro-services in the backend. In which case it would have looked something like this:
The problem with this approach is that, as the application grows, the frontend will get overly complex and become a ‘frontend monolith’. This is something you want to avoid. Full stop. I opted to build with micro-frontends and structure the app vertically, around its capabilities. With this approach, each capability is ‘owned’ by an independent team. This team is responsible not only for the database layer and back-end but also for the frontend.
The new diagram looks like this:
In order for the user to have a unified and consistent experience, I integrated these micro-frontends into a very thin layer:
In essence, micro-frontends are a way to more efficiently organize an application. By extending the concept of micro-services to the frontend, teams can work independently to deliver new functionality to end users.
How to implement micro-frontends?
Even though MiSongs is a very simple app, I’ve chosen to break it up into 4 independent applications to show you a few different approaches to implementing micro-frontends.
If you read through the code for the MiSongs app, you’ll notice that it’s composed of 4 applications:
Artists: Lists artists by genre.
(React SPA frontend + Express.js backend)
Songs: Lists songs by artist.
(React SPA frontend + Express.js backend)
Playlist: Adds songs to a playlist.
(React SPA frontend + Express.js backend)
Parent: Integrates Artists, Songs, and Playlist so that they can be used as a single application.
(React SPA frontend)
From now on, when I write Parent
, I’m referring to the application that integrates all micro-frontends, as mentioned above.
The main challenges with micro-frontend applications are usually integration, communication between services, error handling, authentication, and styles. So we’re going to address how each of these was handled in MiSongs.
Integration
In MiSongs, I opted for integration to happen at runtime
in 3 distinct steps:
1. Webpack in each micro-frontend generates index.[hash].js
umd bundle and index.[hash].css
. The [hash]
in the asset name is necessary to avoid browser cache.
module.exports = {
mode: 'production',
entry: `../client/src/embed.js`,
output: {
library: 'Artists',
libraryTarget: 'umd',
filename: 'index.[hash].js',
path: path.resolve(__dirname, '../server/public/embed')
},
// ...
}
2. The micro-frontend backend exposes an endpoint from which Parent
(the integration layer) fetches the paths to index.[hash].js
and index.[hash].css
. These are the only assets necessary for the micro-frontends to run within the Parent
.
/*
* Generates { js: 'path/to/index.[hash].js', css: 'path/to/index.[hash].css' }
* based on files available in /public/embed directory
*/
function getPathToEmbedAssets() {}
/**
* Exposes paths to embed assets
*/
app.get('/api/embed-assets', (req, res) => {
res.json(getPathToEmbedAssets());
});
3. Parent
calls the micro-frontends’ endpoint /embed-assets
to fetch the embed assets and inject them into the page:
export function loadScript(url, name) {
let promise;
if (_scriptCache.has(url)) {
promise = _scriptCache.get(url);
} else {
promise = new Promise((resolve, reject) => {
let script = document.createElement('script');
script.onerror = event => reject(new Error(`Failed to load '${url}'`));
script.onload = resolve;
script.async = true;
script.src = url;
if (document.currentScript) {
document.currentScript.parentNode.insertBefore(script, document.currentScript);
} else {
(document.head || document.getElementsByTagName('head')[0]).appendChild(script);
}
});
_scriptCache.set(url, promise);
}
return promise.then(() => {
if (global[name]) {
return global[name];
} else {
throw new Error(`"${name}" was not created by "${url}"`);
}
});
}
export function loadStyle(url) {
new Promise((resolve, reject) => {
let link = document.createElement('link');
link.onerror = event => reject(new Error(`Failed to load '${url}'`));
link.onload = resolve;
link.async = true;
link.href = url;
link.rel = 'stylesheet';
(document.head || document.getElementsByTagName('head')[0]).appendChild(link)
});
}
I’ve used runtime
in the frontend. But there are other integration options as well:
Backend integration at runtime with server-side includes (SSIs):
Server Side Includes are a great option if the content needs to be rendered on the server. SSIs are widely supported and relatively easy to configure. The only downside to using them is that, if one of the micro-frontends is slow, then the whole experience will be degraded.
Frontend integration at build time with NPM:
Instead of exposing an endpoint
from which Parent
can fetch the path to the required assets (like we did in MiSongs), we can export the necessary assets of each micro-frontend as an NPM module. The downside to this approach is that we have to redeploy Parent
every-time there’s a change to any micro-frontend.
Communication between services
MiSongs uses Custom DOM Events to communicate between services. For example, when an artist is selected, the Artists micro-frontend dispatches a custom event:
// ...
class ArtistsListItem extends React.Component {
onClick = (e) => {
//...
window.dispatchEvent(
new CustomEvent(ARTISTS_SELECT_ARTIST, { detail: { artist: name } })
);
}
render() {
// ...
return (
);
}
}
// ...
Then, the Songs micro-frontend listens to it and responds accordingly: displaying the songs of the selected artist:
class SongsContainer extends React.Component {
componentDidMount() {
window.addEventListener(ARTISTS_SELECT_ARTIST, this.fetchSongs);
this.fetchSongs({ detail: { artist: this.state.artist } });
}
componentWillUnmount() {
window.removeEventListener(ARTISTS_SELECT_ARTIST, this.fetchSongs);
}
}
I like to use custom DOM events because they facilitate communication and still allow the different components to be decoupled from each other. In this case, I used custom DOM events to exchange information amongst micro-frontends. Depending on your use-case, one of the following options may be more suitable:
React component props:Parent
can pass the state of a micro-frontend around to other micro-frontends using react component props.
Redux store:
Each micro-frontend UMD can expose a reducer for example that can be integrated into Parent’s shared redux store as such:
{
Component: Songs, reducer: songsReducer, actions: songsActions
}
Backend:
Micro-frontends can also communicate via the backend. If you opt for this method, it’s highly recommended that you don’t share the same endpoints meant for the micro-frontends frontend. This will avoid dependency/coupling issues. For example, if the Songs app needs to talk to the Artists app backend, it will do so via endpoints dedicated to other micro-frontends, not the endpoints used by the frontend of Songs.
Error Handling
Each micro-frontend is responsible for handling its own errors. The aim is to prevent errors from one layer affecting other integrated micro-frontends. In MiSongs, I leveraged the React 16 error boundary feature to do this.
Each micro-frontend should be wrapped with an error boundary:
class SongsContainer extends Component {
render() {
// ...
return (
);
}
}
You can think of error boundary
as a React component that implements the componentDidCatch
lifecycle method:
error_boundary.js
class ErrorBoundary extends Component {
//...
componentDidCatch(error, info) {
this.setState({ hasError: true });
}
render() {
return this.state.hasError
? (
Something went wrong
) : this.props.children;
}
}
Authentication
For the sake of brevity, I did not implement authentication in MiSongs. By looking at the code below, you can see that the intent is to use JWT. On lines 15, 20 and 23 Parent
passes as React component props an authentication token so each micro-frontend can communicate with their respective backend.
import React from 'react';
import MicroFrontends from './microfrontends';
import './app.css';
export default function App() {
return (
MiSongs
);
}
To make this work: Parent
calls an authentication service, gets a JSON web token and shares this token with the integrated micro-frontends. That way, they can communicate with their respective backend securely. MiSongs & Authentication would look like this:
Styles
The CSS of each micro-frontend is fetched from their respective /embed-assets
endpoint and then injected Parent.
The downside to this approach is that of potential conflicts. That can be avoided by namespacing CSS selectors or using web components with shadow dom.
Summary
I hope this article got you excited about the future of this architecture. Micro-frontends might initially appear to be just another way to structure an application, but it’s one that I believe has countless benefits – especially for large teams. These include, but are certainly not limited to:
Rapid development:
Teams can develop and deploy features independently.
High flexibility:
Developers can move at their own pace and use the most appropriate technology for the job.
Better organization:
Applications made of components focused on business capabilities.
Fault isolation:
If a micro-frontend breaks, the rest of the application still works
Thanks to micro-frontends, all the benefits that are usually found in applications built with micro-services, can now be leveraged in the frontend.
Are you searching for your next programming challenge?
Scalable Path is always on the lookout for top-notch talent. Apply today and start working with great clients from around the world!