Microservices & Distributed Systems in General
The growth in popularity of distributed systems is not without reason. Organisations have found that the separation of workstreams along domains / business capabilities has provided teams with a greater degree of product ownership. There is an increase in the overall complexity of the aggregated services of course, as interactions between services have to be agreed (often between multiple teams), but the advantages of independently deployable & scalable components and greater team autonomy should make microservices an attractive option for most projects.
There is a caveat to this however: the term “microservice” is pretty much exclusively used to refer to backend web services. These are services that are usually obscured behind an API gateway and a user interface of some kind. There have been lots of weird and wonderful innovations to improve the way we design and build distributed systems, but that effort is primarily focused around improving consistency and availability for your backend services (not to mention the countless frameworks, plugins and tools to marry with whatever patterns you decide to go with for your system). This isn’t exactly surprising, if you’re going for as close to perfect as possible for your system’s consistency and availability then you’re going to focus on the services that are actually performing the tasks required of the system.
User & Developer Experiences
If we move out of the system’s boundary and look at how our users see it from an external view, we’ll find that they don’t (or rather they really shouldn’t) see anything that gives away its nature or underlying architecture. Users expect a seamless experience when they interact with an application, aesthetically as well as functionally. If your interface seems to change its design system in places, that’s going to make your system look like a patchwork of different bits of software and the illusion of a single consistent application is broken. If your interface doesn’t have clear and unified paths for performing actions, then your users may not be able to use it and you’ve suddenly got a very serious problem.
These risks, combined with the fact that interfaces like web apps are essentially just bundles of static assets that users have access to, has led to the default architectural option being some flavour of monolith for frontend applications. Now I’m sure I don’t have to go through the effort of explaining where monolithic applications fall short, those of you who haven’t had the pleasure of building or maintaining one will undoubtedly have heard tales from those who have. However, it’s worth touching on some techniques that have been used to attempt to distribute work on a monolith between different teams.
A module-based approach can seem like a good idea on first consideration, as you can neatly divide up an application and give ownership of them to various teams. What should also be considered is the aggregation of these modules into the final artifact. At some point, these components are going to have to be bundled together, tested, packaged into a deliverable and deployed. The coupling between these modules is usually tight, which increases the likelihood of changes in one component impacting another. If this is the case, then you’re not making the most of a distributed system since your teams will still have to negotiate when making changes that affect the interfaces between their components.
Now that they’ve been put into the appropriate context, we can finally start talking about micro frontends.
Micro Frontends: a New Alternative
Let’s start with a (fairly vague) definition:
A micro frontend is a semi-independent component that can be independently deployed, and dynamically integrated into, a user interface.
This doesn’t really do much for us, so let’s dig into it a bit. What we’re basically talking about is an extension of the thinking that brought microservices into being - what if we cut up our frontend along some meaningful boundaries and developed them separately? You can split the work between teams and let them build their own components in their own way, which is the first advantage this approach brings. Each team doesn’t have to negotiate with every other team to be able to use their preferred tools, since they own the development and deployment aspects of their product. You could have teams using completely different JS libraries, built using different CI tools and deployed to different platforms, and you can still integrate their work together to form a single seamless frontend. This approach will be appreciated by the teams, as they’re freed from the need to agree with all other teams on what tools they should all use.
Since your teams can all go off and deliver the functionality they’re responsible for, you can also bring in more contributors without impacting developer experience (please note I’m emphasizing the word “can”, as simply throwing bodies haphazardly at a project tends to make things worse instead of better). As long as your frontend is decomposed sensibly, and teams are working together to communicate across their component boundaries properly then you shouldn’t have any problems.
NOTE: At this point it’s worth mentioning that micro frontends are still evolving as a concept and there are lots of ways to use them, so take some of this with a pinch of salt. You should apply these methods in a way that works best for your project, but I can speak from experience that the techniques mentioned in this article have worked for my past projects.
If we take a standard web application and start cutting it up, we can see some clear potential micro frontends:
Here we can see that there are micro frontends to be made for the navigation bar at the top of the page, the cart summary popup, and the store item components. In theory you could have each of these developed and owned by different teams, and integrated together into a single application.
Integration
It’s all well and good to develop parts of an application in isolation, but at some point, you’re going to have to stitch everything together. For microservices this is usually achieved with an API gateway, which serves as the entry point into a system and sometimes takes care of cross-cutting concerns such as authentication pathways. Micro frontends also have a single entry point when a user accesses them, usually referred to as a container or root component. When the application is loaded up this is what is given to the browser, and within it is the code required to load up other components from different micro frontends.
The difference between micro frontends and module-based monoliths should be made clear here; the container application will dynamically load components into the browser DOM from remote sources when they are needed, the initial bundles of JS and HTML that are returned from the user’s initial request do not contain any components belonging to other micro frontends. Instead of this, the root component will contain URLs to various micro frontend resources that are retrieved separately (these could be hosted at a different address, built by a different team using different technologies, and deployed using a different cloud services provider). This gives teams the advantage of being able to deploy a new version of a micro frontend, and have this new version become immediately available through the root component without having to pull new versions into any other components and build them again. You will still need to make sure that any contracts you have established between components are still being respected of course, but you can use the usual techniques for dealing with breaking changes when required.
State & Communication
Building an application with completely isolated components would be a very straightforward thing to do, but I’m sure we’re all aware that it is rarely a scenario that comes up on actual projects. It’s usually inevitable that components will eventually need to communicate with each other, responding to either the user or another system’s actions. Now one way to tackle this would be by following the unidirectional data flow pattern, utilizing libraries like Redux to manage state. This pattern is useful for decoupling UI components from state modifications, but the concept of a central state store presents some problems for us in an application composed of micro frontends.
It would be tempting to keep a state store in the root component and have this passed into micro frontends when they are loaded. This would give each micro frontend access to the same state store, allowing them to share data with each other and update themselves when any changes to the state object are made. The problem with this is that you are adding a dependency on the type of state management library the root component is using, which goes against the idea that each micro frontend should be independent. Micro frontends should be able to differ in their implementation without affecting other components in the final application, so restricting them in this way should be avoided wherever possible.
We can allow each micro frontend to manage its own internal state, but there is still the problem of communication. It could be tempting to define interfaces that are implemented in each micro frontend and made available to any component in the application that needs to use them, but this can create tightly coupled micro frontends which makes things difficult to maintain and augment. It is here that we could get some benefit from thinking about this in a different way.
Instead of thinking about side-effects of actions as something that a component applies to another component through a function call, we can think about an action as an event that is broadcasted via a message broker. By doing this we can keep things loosely coupled, allowing easy integration with other components through subscribing to particular message channels or topics. For example, if we had a shopping application with an “add to cart” button, this button could trigger an event that all interested components (a cart contents component for example) could subscribe to. We can use a number of tools (my current preferred choice being Postal.js) to achieve this, adding relevant data (item IDs etc.) into the body of the event if required.
An advantage of this event-based approach is that you can document your APIs to be used by other teams when integrating with your micro frontend. Tools like AsyncAPI excel at this, giving you a neat, unified definition of all the channels and events that your component(s) will potentially emit, and what they signify. You can also version your events as you would for event-driven systems, allowing for gradual phasing out of old event processing as your system matures and changes. Your API specification can also include information on the build tools you are currently using to export your micro frontend, and the dependencies that it is set up to share (shared dependency resolution is a feature of a number of frameworks used to integrate micro frontends, and would be very important for teams to see when preparing to integrate with your components).
Potential Problems
Building a micro frontend-based application has many advantages, but there are also some potential issues that you should be aware of so your team are spared some painful problems in the future.
- Integration technology alignment: you will have to ensure that all teams working on micro frontends are working with the same technologies for exporting their components, as the root application will need to load them all in. There are various options out there, Webpack’s Module Federation plugin and single-spa to name a couple. Module Federation is very flexible, allowing you fine-grained controls over things like shared dependencies. However, single-spa is very quick to get set up and comes with very useful run configurations out of the box (to assist with local development and running micro frontends in isolation). I have had success in the past with the Webpack solution, but you should choose the tool that’s going to work best for your team(s).
- Performance: as your development teams are given the freedom to choose the libraries that they use in their micro frontends, there is a risk that your application will become bloated. There are patterns and techniques you can introduce to mitigate this, both technical and organisational. If you have dependencies that are used in multiple micro frontends, you can configure whatever tool you’re using for builds to use dependencies that are shared by the root component. For example, if you’re using React in a number of micro frontends then you can add this in the root as a shared dependency in your Webpack or single-spa config, and then any micro frontend that uses React will be able to load without having a duplicate version of React bundled into its own JS files. You can also collaborate between your various teams to select various preferred libraries, which will limit the amount of different dependencies being loaded into the final application.
- Debugging: As you can imagine, debugging an application with lots of event-driven micro frontends can get quite complicated, especially when initially integrating new components into an application. If you’re using an event bus like Postal.js, then using a plugin like postal.diagnostics can help you track events passing over the boundaries separating your micro frontends. There can also be some difficulties when working with your build tool to correctly bundle and load micro frontends into an application. This can be very difficult to debug when you’re having loading issues, so I’d advise creating a scaffold with a bare-bones implementation for exporting a micro frontend using something like Yeoman. With this you can tailor your generator to suit the functionality that your team may need for preparing a new component for integration, decreasing the possibility of running into integration issues when introducing a new micro frontend into your application.
- Styling clashes: since you will be loading components built by different teams into the same DOM, you’ll need to put measures in place to avoid CSS class clashes. This can be solved by agreeing to use prefixes in all your CSS classes, or using something like CSS modules.
Conclusion
Micro frontends have a lot of potential if used correctly. It is possible to build large, complex applications by allowing different teams to own part of the web application, but you should take care before you jump straight into using them. As with most patterns and techniques, preparation is key. As long as your teams are communicating properly, and cross-cutting concerns are being handled in a way that is understood by all involved then you shouldn’t run into any problems (at least none that can’t be solved in a relatively straightforward manner).