Micro-frontends is a type of software architecture for building distributed frontend applications. In this architecture, a frontend application is composed of smaller frontend apps that are LEAN:
Executed and deployed independently
Autonomously developed and managed by a small team
Narrowed to a business domain
LEAN is an acronym, that I’ve made up, for four design principles, intended to design micro-frontends that are maintainable, scalable, and lightweight.
As we’ll see in this post, microservices and micro-frontends share a lot of principles. In fact, the list of principles above could be used to describe microservices as well.
In this article I won’t cover the how and the why of micro-frontends. In this article I’ll focus only on what principles we can use to define what a micro-frontend is.
Coupling normally refers to the degree to which code depends on other code. To build a loosely coupled architecture we must create strong boundaries. Creating strong boundaries between micro-frontends works because they are a barrier to references between micro-frontends.
However, coupling doesn’t only affect code. Coupling in a wider perspective can measure different types of dependencies.
Suppose two micro-frontends that depend on the following type definition:
function useLocale(): string
If each micro-frontend calls
useLocale(), we would expect that each call in each micro-frontend returns the same value. Otherwise each micro-frontend would be displayed in a different language, which is probably not what we want. Therefore each micro-frontend doesn’t only depend on code, but also on some data that can change during execution time. We can think of that data as a state dependency.
Let’s create a new version V2 of
useLocale that has a required argument:
function useLocale(default: string) : string
This raises a question, which version of
useLocale is a given micro-frontend depending on? If a micro-frontend calls
useLocale V2 without passing the
default required argument it will error. How do we fix that?
We need to create well defined interfaces between boundaries. This way we can provide the right dependencies in a given context. We can also avoid the previous issue by completely removing versioning, which I would not recommend for large projects.
Well defined interfaces can also help optimise what code needs to be downloaded at runtime. If two micro-frontends depend on the same code dependency and version we only need to download it once. Sharing dependencies helps make our micro-frontends lightweight.
Failure to create strong boundaries with well defined interfaces will make our distributed apps dangerously unpredictable. Will you want to deploy your micro-frontends frequently if you are not confident that they won’t break the system? Probably not.
Microservices are small services that are executed independently. They are small, even so, microservices are still standalone services.
Similarly to microservices and services, each micro-frontend app should still be, guess what, a standalone app. This may sound obvious but it’s important, an app is a program that can be executed independently. We should be able to execute micro-frontend apps independently just like any app.
If micro-frontends can run as standalone applications it means that they are also independently developable, which improves developer experience. That may look like an optional nice-to-have for some people but being able to execute micro-frontends independently has an important side effect: it’s unlikely that a highly coupled micro-frontend is executed independently.
It’s possible to run highly coupled micro-frontends independently, but you’ll need to write custom code to handle different cases depending on the context, and it’ll get harder as the system grows. So, if you can easily execute your micro-frontends in isolation chances are that they are loosely coupled.
Front-end monoliths can be broken up into packages that can be independently published to a registry, like
npm for instance. If we can publish packages independently, why do we need micro-frontends? Before I answer that question, can you tell me what version of package X is request 1 going to execute in the following sequence diagram?
Request 1 executed version 1.0.0 of package X. Now, what version of package X is request 2 executing in the following sequence diagram?
Both request 1 and request 2 execute version 1.0.0 of package X because the monolith was built only once. The monolith was not built after package X v1.0.1 was published.
Both packages and micro-frontends are integrated into a whole before users interact with them. Build time integration requires to building and deploying the whole when any of its parts are built and deployed. Thus, packages can’t be considered micro-frontends because they violate the executed and deployed independently principle.
Micro-frontends integration happens at runtime either on the server or on the browser. Micro-frontends are integrated into something often called a shell application or a container application. I prefer the term shell because it better indicates the idea of a lean layer. Also, the term container could be misleading in the context of front-end development.
Given the following diagram, in which a micro-frontend is being server-side integrated with a shell, what version of micro-frontend X is request 2 executing?
Request 2 executed micro-frontend X version 1.0.1.
So, why are micro-frontend independent deployments so cool if we can also publish packages independently? Because with micro-frontends we can release and execute code independently without having to rebuild and redeploy the whole.
This type of runtime integration comes with its own challenges, which are not within the scope of this post.
If micro-frontends are independently developed, deployed, and executed it means that autonomous teams can own them end-to-end. This makes micro-frontends very scalable from a human resources perspective. It also reduces teams’ cognitive load and so increases dev productivity.
Micro-frontends work well when we have many teams working on different parts of the codebase. If we have a team responsible for the entire codebase, then the complexity that micro-frontends bring will increase their cognitive load and possibly reduce their productivity. In that case, it’s probably better if we start with a monolith first approach. We can upgrade to a micro-frontends architecture when the organisation is ready.
In the context of this post we are going to define “domain” as a sphere of knowledge, influence, or activity. Domains can contain subdomains:
In many cases, domains on the frontend possibly match domains on the backend.
The domains that we define should determine the boundaries of the micro-frontends that we design. Defining proper domains should reduce coupling between micro-frontends, and it’ll set clear team-micro-frontend relationships.
In some organizations, each Internet subdomain can be deployed as a micro-frontend. That view of micro-frontends can be limiting if we can’t have more than one page in an Internet subdomain where each page is a LEAN micro-frontend itself. For instance, Search could be a business subdomain within the Web Player business domain.
In modern Web applications we may want to have a page that contains more than one business domain or subdomain. In the following example, the checkout page displays both a “checkout” micro-frontend owned by the Checkout team and also an “other customers also bought” micro-frontend owned by the Catalogue team.
Defining the right domains and subdomains is a critical part of any software architecture, and micro-frontends are no different. I personally find this part very exciting.
If you are interested in learning more about LEAN micro-frontends don’t hesitate to subscribe to my newsletter.