La Decisión Arquitectónica
Tres enfoques merecen evaluación:
- Module Federation 2.0 (via Rspack), Compartir módulos en runtime, ecosistema maduro, específico de webpack/rspack
- Native Federation, ES modules + import maps, agnóstico de bundler, más nuevo
- Composición en build-time (paquetes npm), Cada MFE publicado como paquete npm, compuesto en build time
Module Federation 2.0 con Rspack para la aplicación core e import maps como capa de discovery representa la combinación más sólida.
Module Federation 2.0: Qué Cambió
MF 2.0 (shipeado con Rspack 1.x y webpack 5.9x) introdujo tres cosas que lo hicieron realmente usable para producción:
1. Protocolo de manifest. En vez de hardcodear URLs de remotes en la config de webpack, los remotes publican un mf-manifest.json que describe sus módulos expuestos, dependencias compartidas, y entry points:
{
"id": "checkout-mfe",
"name": "checkout",
"metaData": {
"buildHash": "a3f8c2d",
"version": "2.14.0",
"framework": "react@18.3.1"
},
"shared": [
{
"name": "react",
"version": "18.3.1",
"scope": "default",
"strategy": "loaded-first"
}
],
"exposes": [
{
"path": "./CheckoutFlow",
"name": "CheckoutFlow",
"assets": {
"js": { "async": ["checkout-flow.js"], "sync": [] },
"css": { "async": ["checkout-flow.css"], "sync": [] }
}
}
]
}
2. Runtime plugins. Puede interceptar todo el ciclo de vida de federation, antes de cargar un remote, después de resolver dependencias compartidas, on error. Así se puede implementar A/B testing a nivel de micro-frontend:
// host/federation-plugin.js
import { FederationRuntimePlugin } from '@module-federation/runtime';
const ABTestPlugin = () => ({
name: 'ab-test-plugin',
beforeRequest(args) {
const { id, options } = args;
const variant = getABVariant(id);
if (variant) {
options.remotes = options.remotes.map(remote => {
if (remote.name === id) {
return {
...remote,
entry: remote.entry.replace(
'mf-manifest.json',
`mf-manifest-${variant}.json`
)
};
}
return remote;
});
}
return args;
}
});
3. Generación de tipos. MF 2.0 genera declaraciones TypeScript para módulos expuestos y las sincroniza entre micro-frontends via un manifest. No más // @ts-ignore en cada dynamic import.
La Aplicación Host
El host (shell) es mínimo, maneja routing, autenticación, y el chrome de navegación. Cada ruta mapea a un micro-frontend remoto:
// host/src/App.jsx
import { lazy, Suspense } from 'react';
import { init, loadRemote } from '@module-federation/enhanced/runtime';
import { Routes, Route } from 'react-router-dom';
init({
name: 'host',
remotes: [
{ name: 'dashboard', entry: `${window.__MFE_CONFIG__.dashboard}/mf-manifest.json` },
{ name: 'checkout', entry: `${window.__MFE_CONFIG__.checkout}/mf-manifest.json` },
{ name: 'accounts', entry: `${window.__MFE_CONFIG__.accounts}/mf-manifest.json` },
{ name: 'settings', entry: `${window.__MFE_CONFIG__.settings}/mf-manifest.json` }
],
shared: {
react: { singleton: true, requiredVersion: '^18.3.0' },
'react-dom': { singleton: true, requiredVersion: '^18.3.0' },
'react-router-dom': { singleton: true, requiredVersion: '^6.20.0' }
}
});
const Dashboard = lazy(() => loadRemote('dashboard/DashboardApp'));
const Checkout = lazy(() => loadRemote('checkout/CheckoutFlow'));
const Accounts = lazy(() => loadRemote('accounts/AccountsApp'));
const Settings = lazy(() => loadRemote('settings/SettingsApp'));
function App() {
return (
<Shell>
<Suspense fallback={<MFELoadingSkeleton />}>
<Routes>
<Route path="/dashboard/*" element={<Dashboard />} />
<Route path="/checkout/*" element={<Checkout />} />
<Route path="/accounts/*" element={<Accounts />} />
<Route path="/settings/*" element={<Settings />} />
</Routes>
</Suspense>
</Shell>
);
}
El objeto window.__MFE_CONFIG__ lo inyecta el pipeline de deployment. En producción, apunta a URLs de CDN. En desarrollo, los desarrolladores pueden overridear entries específicas para apuntar a localhost:3001, localhost:3002, etc.
Import Maps como Capa de Discovery
Acá encajan los import maps. En vez de hardcodear URLs de MFEs en el bundle JavaScript del host, servimos un import map desde un CDN que el host lee al arrancar:
<script type="importmap">
{
"imports": {
"dashboard-mfe": "https://cdn.example.com/mfe/dashboard/v2.14.0/",
"checkout-mfe": "https://cdn.example.com/mfe/checkout/v3.2.1/",
"accounts-mfe": "https://cdn.example.com/mfe/accounts/v1.8.0/",
"settings-mfe": "https://cdn.example.com/mfe/settings/v2.0.0/"
}
}
</script>
Este import map lo genera el pipeline de CI cada vez que un MFE deployea. La ventaja crítica: es posible actualizar qué versión de un MFE se sirve sin redeployar la aplicación host. ¿Deployeaste checkout v3.2.2? Solo actualizá el import map. ¿Rollback? Actualizá el import map de nuevo. El host nunca cambia.
Estado Compartido: El Problema Difícil
Acá es donde la mayoría de las arquitecturas micro-frontend se caen a pedazos. Cuatro equipos, siete MFEs, y todos necesitan saber quién es el usuario actual, cuáles son sus permisos, y si el carrito tiene items.
La tentación es compartir un store Redux. Esto es desaconsejable. Un store compartido crea acoplamiento implícito entre MFEs, el equipo A cambia un reducer, los selectors del equipo B se rompen, y nadie lo atrapa hasta producción porque deployean independientemente.
En su lugar, un enfoque de tres capas funciona bien:
Capa 1: Contexto de Autenticación (Del Host)
El host es dueño del estado de autenticación y lo expone via un event bus custom:
// host/src/auth-bus.js
class AuthBus {
#listeners = new Set();
#currentUser = null;
setUser(user) {
this.#currentUser = Object.freeze(user);
this.#notify('user-changed', this.#currentUser);
}
getUser() { return this.#currentUser; }
subscribe(callback) {
this.#listeners.add(callback);
if (this.#currentUser) {
callback({ type: 'user-changed', payload: this.#currentUser });
}
return () => this.#listeners.delete(callback);
}
#notify(type, payload) {
const event = Object.freeze({ type, payload });
this.#listeners.forEach(cb => cb(event));
}
}
window.__AUTH_BUS__ = new AuthBus();
Capa 2: Eventos Cross-MFE (BroadcastChannel)
Para eventos que cualquier MFE puede producir y cualquier otro consumir (como "item agregado al carrito"), usamos BroadcastChannel:
// shared-lib/src/mfe-events.js
const channel = new BroadcastChannel('mfe-events');
export function emitMFEEvent(type, payload, source) {
channel.postMessage({ type, payload, source, timestamp: Date.now() });
}
export function onMFEEvent(type, callback) {
const handler = (event) => {
if (event.data.type === type) callback(event.data);
};
channel.addEventListener('message', handler);
return () => channel.removeEventListener('message', handler);
}
Capa 3: URL como Estado (Router-Driven)
El estado de navegación compartido vive en la URL. Si el dashboard necesita linkear a una cuenta específica, navega a /accounts/ACC-123. El MFE de accounts lee sus route params. Sin estado compartido necesario.
La Migración: Patrón Strangler Fig
El monolito no se reescribió. Se estranguló. Acá va un timeline representativo:
Mes 1-2: Levantar el host shell, infraestructura de Module Federation, y pipeline de CI/CD. El host renderizaba el monolito entero como un solo remote "legacy".
Mes 3-4: Se extrajo la página de settings (menor riesgo, menos dependencias) a su propio MFE. Dos ingenieros, dos semanas de código real. El resto fue plomería de CI/CD.
Mes 5-6: Se extrajo checkout (mayor valor de negocio, más complejo). Esto requirió la arquitectura de estado compartido descrita arriba. Tres ingenieros, seis semanas.
Mes 7-10: Se extrajeron dashboard y accounts en paralelo. A esa altura, los patrones estaban establecidos y cada extracción tomó 3-4 semanas por equipo.
Mes 11-12: Se decomisionó el remote del monolito legacy. Lo que quedó fue el host shell + cuatro MFEs independientes.
Errores Comunes
-
Aislamiento CSS. Sin aislamiento CSS enforceado desde el día uno, los estilos globales del MFE A se filtran al MFE B. La solución: Shadow DOM para MFEs nuevos y un plugin PostCSS que scopea todos los selectores para código legacy.
-
Drift de versiones de dependencias compartidas. En tres meses, dos MFEs estaban en React 18.3.1 y dos en 18.2.0. El singleton mode de Module Federation hizo que los hooks de un equipo fallaran silenciosamente. Agregar un check de CI que falle si las versiones de dependencias compartidas divergen más de un minor previene esto.
-
Error boundaries. Cuando un MFE remoto falla al cargar (caída de CDN, problema de red), todo el host crasheaba porque no wrapeamos
loadRemote()en error boundaries apropiados. Ahora cada punto de montaje de MFE tiene un<MFEErrorBoundary>que muestra una UI de retry.
Impacto en Performance
Métricas clave de performance:
| Métrica | Monolito | Micro-Frontends | Delta |
|---|---|---|---|
| Bundle JS inicial | 2.4MB (gzipped 680KB) | 320KB host + MFEs lazy | -53% carga inicial |
| Time to Interactive | 4.2s | 1.8s (host) + 0.6s (primer MFE) | -43% |
| Tiempo de deploy | 22 min (build + E2E completo) | 3-4 min por MFE | -82% |
| Frecuencia de deploy | 2-3/día (todos los equipos combinados) | 3-5/día por equipo | +400% |
La carga inicial mejoró porque los usuarios solo descargan el código de la página que visitan. Los 1.8MB de librerías de charting del equipo de dashboard no se descargan hasta que alguien visita /dashboard.
El tradeoff: hay un breve estado de loading al navegar entre MFEs por primera vez mientras el bundle remoto se descarga. Precargar el MFE más probable siguiente basándose en patrones de navegación del usuario, lo que mitiga esto para el 80% de las transiciones.
Cuándo Tienen Sentido los Micro-Frontends
Los micro-frontends se justifican solo para organizaciones con la estructura de equipo que lo demande. Los micro-frontends son una solución organizacional a un problema organizacional. Con un equipo, un monolito es lo indicado. Con dos equipos, un monorepo con code ownership. Con cuatro o más equipos que necesitan cadencias de deploy independientes, los micro-frontends valen la complejidad.
La tecnología finalmente está lo suficientemente madura como para que el dolor de implementación sea manejable. La guía: arrancar con un monolito. Extraer cuando el dolor organizacional lo demande. Y al extraer, usar el patrón strangler fig. Es el único approach de migración que te deja shipear features a los usuarios cada semana mientras la descomposición pasa en el fondo.