Study ref. FE-01
Micro-Frontend Migration
Component Composition & Module Federation at Scale
The Situation
A B2B SaaS platform had grown from a single-team startup into a six-team product organisation — analytics, payments, settings, notifications, marketplace, and a shared design system — all shipping from one monolithic React application. Every release required a full-suite regression pass and coordination across all six teams to merge a shared release branch.
The consequence was a two-week release cycle that served no team well. A critical payments bug fix sat blocked for nine days waiting for an analytics feature freeze. The notification team's bundle changes broke marketplace routing twice in a quarter. Prop drilling was endemic: user session data threaded through eleven component layers to reach a deeply nested billing widget that needed only the account tier.
Constraints
- No user-visible downtime during migration — feature development continues in parallel
- Shared design system must remain the single source of truth across all micro-frontends
- Teams own their deployment pipelines; no central release coordinator
- Existing React Router v5 routing layer must be preserved until all MFEs are extracted
System Evolution
Left: monolithic React application where all six domains share one build, one router, and one deployment pipeline. Right: module federation target state where each domain MFE deploys independently and the shell orchestrates routing and shared context.
The critical insight: the shell owns routing and authentication context. Each MFE receives what it needs through a typed contract at the module boundary — not through eleven layers of prop threading. The design system is loaded once at the shell level and shared via Webpack Module Federation's singleton scope, eliminating duplicate React instances entirely.
Migration Strategy: Strangler Fig per Domain
Extraction followed domain ownership — the payments team migrated first
because they had the clearest bounded context and the highest pain from
the shared release cycle. Each domain was extracted as a standalone Webpack
5 build, exposed via Module Federation's exposes configuration,
and consumed by the shell via a remote entry point.
During the twelve-week migration, the monolith and MFEs coexisted behind a feature flag at the shell routing layer. Payments could deploy on Tuesday; the monolith's remaining domains deployed on the old fortnightly cycle. Zero coordination required for independent domains.
Client requests pass through a DNS weight gate at the shell boundary; the MFE share climbs from 5% to 100% over each domain's rollout window while the monolith continues serving the remainder — rollback is a single weight change, not a redeploy.
Eliminating Prop Drilling
The session object had been threaded through eleven component layers
because the monolith had no clean boundary at which to inject it. The
shell introduced a typed AuthContext published once at the
root. Each MFE consumed it directly with useAuth() — the
eleven intermediate layers simply stopped carrying the prop.
Where state needed to cross MFE boundaries (e.g. a payments event visible to the notifications MFE), a lightweight event bus was introduced at the shell level. No shared global store — only typed, versioned events flowing in one direction.
Design System Contract
The existing shared component library was promoted to a versioned npm
package. Module Federation's singleton: true flag ensured
only one React instance ran in the browser regardless of how many MFEs
loaded. Breaking changes required a semver major bump and a shell-level
compatibility matrix review — enforced in CI.
Each MFE declared the design system as a peer dependency, not a bundled dependency. The shell owned the resolved version. This eliminated the "double React" problem that had caused hook invariant failures in early proof-of-concept work.
Team Enablement
A contract-first rule governed cross-MFE integration: a team could change anything inside their MFE boundary freely, but changes to the shell contract or shared event schema required an RFC reviewed by all affected teams. This kept teams autonomous without creating invisible coupling — the same principle that hexagonal architecture applies at the backend boundary.
Release cadence per MFE
Down from a mandatory 14-day shared release cycle
Fewer cross-team merge conflicts
Each team owns its own repository and branch strategy
Faster local dev startup
Engineers run only their MFE against a stubbed shell
User-visible regressions
Feature flags and shadow routing prevented any downtime
What I Would Do Differently
The event bus — introduced late as an afterthought for cross-MFE communication — became a source of invisible coupling within three months. Teams added event types without a schema registry, and by month four, the payments MFE was listening to events from six other teams with no versioning contract. A typed event schema registry, enforced in CI from day one, would have prevented this.
The extraction order followed team willingness rather than dependency topology. Analytics, which many other MFEs linked into for instrumentation, was extracted last — meaning for ten weeks, the analytics package was imported from the monolith into newly extracted MFEs via a re-export shim. The shim worked but added complexity that could have been avoided by extracting shared infrastructure MFEs first.