Context
Recently I’ve been researching a way to allow multiple teams to work on a complex web based product. While the product is supposed to look like a single coherent single page application to users, ~8 distributed, cross-functional teams are supposed to work on individual USPs in parallel. “Parallel” means that teams should be able to deploy updates to their feature independent from other teams, without any risk of breaking the whole application or other team’s features. At the same time, teams need to be able to re-use components built by other teams. To reach a coherent user experience and to allow people to switch to other teams without hassle, ensuring consistency across teams was an important consideration as well.
Considered approaches:
- Keep it stupid: Have all the teams work on one single shared SPA codebase. I initially favoured the “no premature scaling” option, to keep the complexity low. Working with eight frontend developers on a single code base had proven to work well. However, that was a tight-knit team sitting next to each other. It did quickly sound unlikely that we’d be able to make it work for two or three times as many developers across multiple countries and timezones.
- A mono-repo setup (e.g with nx). Clear guidance on project structure and some automated scripts (e.g. to prevent widespread usage of global application state) would ensure the independence of deliveries. The fact that the teams would work on a single repository would make it relatively easy to keep the technical tooling and dependencies consistent and share components. Past bad experiences with the complexities of managing deployments with lerna reduced the attractiveness of the mono-repo option. Additionally there was uncertainty whether our first shot at setting up the mono-repo would turn out to be scalable enough to cover the long term application size without requiring large re-organisation (which would then block all teams at the same time) down the line.
- Setting up micro frontends with single-spa as glue. Micro frontends come with the opposite benefits and downsides compared to a mono-repo setup: independent deployments are straight-forward, but keeping the repositories consistent would pose a challenge. Re-sharing components (e.g. via organisation-internal private packages) would be a pain in the ***.
- Going the micro frontend route, but using federated modules, enabled by webpack 5 1. Ending up with micro frontends without adding additional tooling to enable it seems like a clean, “vanilla” way.
Research into module federation with webpack 5
To research, whether module federation is already mature and feature-complete enough, I set up a small example project.
The repository is more or less a concatenation of multiple module federation examples:
- It uses typescript (example).
- It allows for bi-directional host, meaning that any of the federated modules can act as entry-point (example). Routes are exposed by all modules and imported by others, so that any host possesses full routing capabilities (example).
- ~~It allows to re-configure the hosts of federated modules with a configuration cookie. This allows any federated module to use other federated modules from different (hosting) environments. This is important to allow teams to implement against e.g. the staging or production version of other modules, to deploy feature branches of one module without having to spin up others, and to run E2E tests in CI pipelines. 2
- Props are shared as slim global application state; in a production-grade application they would stem from a mix of
.env
configuration and feature flags provided over an open websocket connection.
Detected downsides of federated modules & remedies
- To work on one of the federated modules, all remotes need to be reachable. It’s not possible to work on one application locally without running all applications locally or at least having access to a hosting environment exposing all remote applications.
This is not a big issue. Being able to hook in the production or staging hosted remote applications is comfortable enough. - If one of the remotes is not responding, the accessed module crashes at the first non-lazy import of a federated module.
To resolve, free thehost
application’s bootstrapping from non-lazy imports of remote application’s modules. This e.g. could likely be archived by changing the import ofsettingsApp/routes
inhost/src/App.tsx
to be lazy, and adding a second router<Switch>
wrapped in its own<Suspense>
and<ErrorBoundary>
, which would render theRoute
s of the lazy loadedsettings
application. A failing import of a remote application should then only affect routes exposed by the remote application instead of the whole accessed application. - Applications need to load their own
remoteEntry.js
to resolve circular dependencies. An example: when starting thehost
app and accessing/settings
,host
renders the remote modulesettings/page/SettingsPage.tsx
, which in turn importshost/utils/cookie.ts
via the import pathhostApp/cookie
. The host environment doesn’t know how to resolve imports starting withhostApp/
without the import of its ownremoteEntry.js
.host
having to load its ownremoteEntry.js
is a duplication of other JS bundles loaded becauseremoteEntry.js
(also) contains the exported modules and shared eagerly loaded dependencies.
That redundant imports are the only solution seems unlikely given that every application is aware of its own name, as specified inModuleFederationPlugin
s configuration object’sname
property. This is such a basic case that it seems likely that there is a better way. - There’s no polished best practises for stuff like sharing module type definitions (but at least one example and discussions).
Given webpack’s buy-in and support by other module bundlers, best practises should quickly appear. There needs to be a willingness to adapt those as they come up. - Tree shaking is not possible for shared dependencies. Using different dependency versions in different applications could introduce subtle issues in some cases, and updating synchronously could counter the idea of application development independence.
- Recent webpack beta updates still changed module federation plugin capabilities, defaults, and configuration. Adopting module federation would likely mean having to migrate through a couple of smaller breaking changes in the coming months.
Locking in the webpack version and only upgrading with the stable release would mean only a single adaptation would be required. The expected costs are not high, because the amount of configuration is limited.
Conclusion
In the end we decided not to go forward with webpack module federation. While it seemed that most issues could be overcome or would be ironed out during the remaining beta phase of webpack 5, the expected required time investment and the remaining insecurities didn’t make it contender number one for a project with delivery pressure.
Footnotes
-
There’s promising signs that rollup will support it. At that point it’s safe to call module federation an — albeit unofficial – “JS ecosystem standard”. ↩
-
That use case has been solved much better. My solution had stopped to work with the webpack 5 beta 21 update anyway. ↩