DEV Community

Gustavo Ramirez
Gustavo Ramirez

Posted on

Micro-frontends con Module Federation desplegados en AWS con CDK y CloudFront

Los micro-frontends son a Module Federation lo que los microservicios son a REST: una forma de descomponer una monolito frontend en pedazos desplegables independientemente. Llevo dos años manteniendo un micro-frontend en producción con 4 equipos distintos y quiero compartir cómo lo estructuramos en AWS, los errores que cometimos, y cuándo realmente vale la pena.

Qué resuelve realmente

flowchart TB
    subgraph Monolito[Monolito frontend]
        M1[Equipo A trabaja en header]
        M2[Equipo B trabaja en checkout]
        M3[Equipo C trabaja en perfil]
        M4[Un solo deploy]
        M5[Un solo build]
        M6[Todos comparten dependencias]
    end

    subgraph MF[Micro-frontends]
        F1[Equipo A: header-app<br/>deploy independiente]
        F2[Equipo B: checkout-app<br/>deploy independiente]
        F3[Equipo C: profile-app<br/>deploy independiente]
        F4[Shell app compone todo]
    end

    style MF fill:#f39c12,color:#fff
Enter fullscreen mode Exit fullscreen mode

Antes de seguir: si tu equipo son menos de 20 personas, probablemente no necesitas esto. Los micro-frontends tienen costos reales (complejidad, orchestración, versionado de dependencias). Solo valen la pena cuando:

  • Tienes 4+ equipos trabajando en la misma app.
  • Los equipos necesitan ciclos de deploy distintos.
  • Hay dominios de negocio claramente separados.

La arquitectura en AWS

flowchart LR
    User[Usuario] --> CF[CloudFront]
    CF -->|/| Shell[Shell App<br/>S3 bucket-shell]
    CF -->|/checkout/*| CheckoutBundle[Checkout Remote<br/>S3 bucket-checkout]
    CF -->|/profile/*| ProfileBundle[Profile Remote<br/>S3 bucket-profile]
    CF -->|/products/*| ProductsBundle[Products Remote<br/>S3 bucket-products]

    Shell -.dynamically loads.-> CheckoutBundle
    Shell -.dynamically loads.-> ProfileBundle
    Shell -.dynamically loads.-> ProductsBundle

    style CF fill:#146eb4,color:#fff
    style Shell fill:#e74c3c,color:#fff
Enter fullscreen mode Exit fullscreen mode

Cada micro-frontend vive en su propio bucket S3, con su propio pipeline. CloudFront los une bajo un mismo dominio.

Configurando Module Federation en Webpack

El shell app es el contenedor:

// shell/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
const HtmlWebpackPlugin = require('html-webpack-plugin');
const deps = require('./package.json').dependencies;

module.exports = {
  entry: './src/index.ts',
  mode: 'production',
  output: {
    publicPath: 'auto',
    clean: true,
    filename: '[name].[contenthash].js',
  },
  resolve: {
    extensions: ['.ts', '.tsx', '.js', '.jsx'],
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: 'ts-loader',
        exclude: /node_modules/,
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      remotes: {
        checkout: `checkout@${process.env.CHECKOUT_URL}/remoteEntry.js`,
        profile: `profile@${process.env.PROFILE_URL}/remoteEntry.js`,
        products: `products@${process.env.PRODUCTS_URL}/remoteEntry.js`,
      },
      shared: {
        react: {
          singleton: true,
          requiredVersion: deps.react,
          strictVersion: false,
        },
        'react-dom': {
          singleton: true,
          requiredVersion: deps['react-dom'],
        },
        'react-router-dom': {
          singleton: true,
          requiredVersion: deps['react-router-dom'],
        },
        '@tanstack/react-query': {
          singleton: true,
          requiredVersion: deps['@tanstack/react-query'],
        },
      },
    }),
    new HtmlWebpackPlugin({
      template: './src/index.html',
    }),
  ],
};
Enter fullscreen mode Exit fullscreen mode

Un remote (por ejemplo checkout):

// checkout/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
const deps = require('./package.json').dependencies;

module.exports = {
  entry: './src/index.ts',
  mode: 'production',
  output: {
    publicPath: 'auto',
    filename: '[name].[contenthash].js',
    uniqueName: 'checkout',
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'checkout',
      filename: 'remoteEntry.js',
      exposes: {
        './CheckoutApp': './src/App',
        './CartWidget': './src/components/CartWidget',
        './useCart': './src/hooks/useCart',
      },
      shared: {
        react: {
          singleton: true,
          requiredVersion: deps.react,
        },
        'react-dom': {
          singleton: true,
          requiredVersion: deps['react-dom'],
        },
        'react-router-dom': {
          singleton: true,
          requiredVersion: deps['react-router-dom'],
        },
      },
    }),
  ],
};
Enter fullscreen mode Exit fullscreen mode

Carga dinámica en el shell

El shell app carga los remotes dinámicamente con lazy + error boundaries:

// shell/src/App.tsx
import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { ErrorBoundary } from 'react-error-boundary';
import { Layout } from './components/Layout';
import { Loading } from './components/Loading';
import { ErrorFallback } from './components/ErrorFallback';

const CheckoutApp = lazy(() => import('checkout/CheckoutApp'));
const ProfileApp = lazy(() => import('profile/ProfileApp'));
const ProductsApp = lazy(() => import('products/ProductsApp'));

export function App() {
  return (
    <BrowserRouter>
      <Layout>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route
            path="/checkout/*"
            element={
              <ErrorBoundary FallbackComponent={ErrorFallback}>
                <Suspense fallback={<Loading />}>
                  <CheckoutApp />
                </Suspense>
              </ErrorBoundary>
            }
          />
          <Route
            path="/profile/*"
            element={
              <ErrorBoundary FallbackComponent={ErrorFallback}>
                <Suspense fallback={<Loading />}>
                  <ProfileApp />
                </Suspense>
              </ErrorBoundary>
            }
          />
          <Route
            path="/products/*"
            element={
              <ErrorBoundary FallbackComponent={ErrorFallback}>
                <Suspense fallback={<Loading />}>
                  <ProductsApp />
                </Suspense>
              </ErrorBoundary>
            }
          />
        </Routes>
      </Layout>
    </BrowserRouter>
  );
}
Enter fullscreen mode Exit fullscreen mode

El ErrorBoundary alrededor de cada remote es crítico: si un equipo rompe su deploy, solo su sección se rompe, no toda la app.

Compartir estado entre micro-frontends

Esto es lo más complicado. Hay varias estrategias:

Estrategia 1: Shared package exporteable

// shared/src/state/auth.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface AuthStore {
  user: User | null;
  token: string | null;
  setUser: (user: User) => void;
  logout: () => void;
}

export const useAuthStore = create<AuthStore>()(
  persist(
    (set) => ({
      user: null,
      token: null,
      setUser: (user) => set({ user, token: user.token }),
      logout: () => set({ user: null, token: null }),
    }),
    { name: 'auth-storage' }
  )
);
Enter fullscreen mode Exit fullscreen mode

Este store se exporta y se consume en todos los remotes. Como zustand está en shared: { singleton: true }, es la misma instancia en toda la app.

Estrategia 2: Custom events

// shared/src/event-bus.ts
type EventMap = {
  'cart:item-added': { productId: string; quantity: number };
  'cart:cleared': void;
  'user:logged-in': { userId: string };
  'user:logged-out': void;
};

class TypedEventBus {
  private target = new EventTarget();

  emit<K extends keyof EventMap>(type: K, detail: EventMap[K]) {
    this.target.dispatchEvent(new CustomEvent(type, { detail }));
  }

  on<K extends keyof EventMap>(
    type: K,
    handler: (detail: EventMap[K]) => void
  ): () => void {
    const wrapper = (e: Event) => handler((e as CustomEvent).detail);
    this.target.addEventListener(type, wrapper);
    return () => this.target.removeEventListener(type, wrapper);
  }
}

export const eventBus = new TypedEventBus();
Enter fullscreen mode Exit fullscreen mode

Uso:

// En checkout
import { eventBus } from 'shared/event-bus';

function AddToCartButton({ product }) {
  return (
    <button onClick={() => eventBus.emit('cart:item-added', {
      productId: product.id,
      quantity: 1,
    })}>
      Agregar
    </button>
  );
}

// En shell o cualquier otro remote
import { useEffect, useState } from 'react';
import { eventBus } from 'shared/event-bus';

function CartBadge() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    return eventBus.on('cart:item-added', () => {
      setCount(c => c + 1);
    });
  }, []);

  return <span>🛒 {count}</span>;
}
Enter fullscreen mode Exit fullscreen mode

Stack CDK completo

Cada micro-frontend tiene su propio stack. Aquí el patrón reutilizable:

// infra/lib/microfrontend-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
import { Construct } from 'constructs';

export interface MicrofrontendStackProps extends cdk.StackProps {
  name: string;
  sourcePath: string;
  subdomain: string;
  hostedZone: cdk.aws_route53.IHostedZone;
}

export class MicrofrontendStack extends cdk.Stack {
  public readonly distribution: cloudfront.Distribution;
  public readonly bucket: s3.Bucket;
  public readonly url: string;

  constructor(scope: Construct, id: string, props: MicrofrontendStackProps) {
    super(scope, id, props);

    this.bucket = new s3.Bucket(this, 'Bucket', {
      bucketName: `mfe-${props.name}-${this.account}`,
      encryption: s3.BucketEncryption.S3_MANAGED,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      cors: [
        {
          allowedOrigins: ['*'],
          allowedMethods: [s3.HttpMethods.GET, s3.HttpMethods.HEAD],
          allowedHeaders: ['*'],
          maxAge: 3600,
        },
      ],
    });

    // CRÍTICO: CORS permisivo para que el shell pueda cargar remoteEntry.js
    // desde un dominio diferente

    this.distribution = new cloudfront.Distribution(this, 'Distribution', {
      defaultBehavior: {
        origin: origins.S3BucketOrigin.withOriginAccessControl(this.bucket),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        cachePolicy: new cloudfront.CachePolicy(this, 'CachePolicy', {
          // remoteEntry.js NO debe cachearse agresivamente
          defaultTtl: cdk.Duration.minutes(5),
          minTtl: cdk.Duration.seconds(0),
          maxTtl: cdk.Duration.hours(1),
          headerBehavior: cloudfront.CacheHeaderBehavior.allowList(
            'Origin',
            'Access-Control-Request-Method',
            'Access-Control-Request-Headers'
          ),
          queryStringBehavior: cloudfront.CacheQueryStringBehavior.none(),
          enableAcceptEncodingBrotli: true,
          enableAcceptEncodingGzip: true,
        }),
        responseHeadersPolicy: cloudfront.ResponseHeadersPolicy.CORS_ALLOW_ALL_ORIGINS,
      },
      additionalBehaviors: {
        // Chunks con hash: cache largo
        '*.chunk.js': {
          origin: origins.S3BucketOrigin.withOriginAccessControl(this.bucket),
          viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
          cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
          responseHeadersPolicy: cloudfront.ResponseHeadersPolicy.CORS_ALLOW_ALL_ORIGINS,
        },
      },
    });

    new s3deploy.BucketDeployment(this, 'Deployment', {
      sources: [s3deploy.Source.asset(props.sourcePath)],
      destinationBucket: this.bucket,
      distribution: this.distribution,
      distributionPaths: ['/remoteEntry.js', '/index.html'],
      prune: true,
      memoryLimit: 512,
    });

    this.url = `https://${this.distribution.distributionDomainName}`;
  }
}
Enter fullscreen mode Exit fullscreen mode

El shell stack apunta a los URLs de los remotes:

// infra/app.ts
const app = new cdk.App();

const checkoutMfe = new MicrofrontendStack(app, 'CheckoutMfe', {
  name: 'checkout',
  sourcePath: '../checkout/dist',
  // ...
});

const profileMfe = new MicrofrontendStack(app, 'ProfileMfe', {
  name: 'profile',
  sourcePath: '../profile/dist',
  // ...
});

const shellStack = new ShellStack(app, 'ShellStack', {
  remoteUrls: {
    checkout: checkoutMfe.url,
    profile: profileMfe.url,
  },
});

shellStack.addDependency(checkoutMfe);
shellStack.addDependency(profileMfe);
Enter fullscreen mode Exit fullscreen mode

Pipeline de CI/CD por equipo

Cada equipo tiene su pipeline:

# .github/workflows/deploy-checkout.yml
name: Deploy Checkout MFE

on:
  push:
    branches: [main]
    paths:
      - 'checkout/**'
      - 'shared/**'

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Build shared
        run: |
          cd shared
          npm ci
          npm run build

      - name: Build checkout
        run: |
          cd checkout
          npm ci
          npm run build

      - name: Run tests
        run: cd checkout && npm test

      - name: Configure AWS
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/GitHubDeployer
          aws-region: us-east-1

      - name: Deploy
        run: |
          cd infra
          npm ci
          npx cdk deploy CheckoutMfe --require-approval never
Enter fullscreen mode Exit fullscreen mode

Versionado de dependencias compartidas

El mayor dolor de cabeza. Si el shell tiene React 18.2 y un remote tiene React 19, las cosas explotan. Estrategias:

1. Pin estricto de versions en shell

El shell es el source of truth. Todos los remotes deben usar las mismas versiones de dependencias críticas.

{
  "dependencies": {
    "react": "18.3.1",
    "react-dom": "18.3.1",
    "react-router-dom": "6.26.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Automatizar el check

// tools/verify-dependencies.ts
import fs from 'fs';
import path from 'path';

const shellDeps = JSON.parse(
  fs.readFileSync('shell/package.json', 'utf-8')
).dependencies;

const critical = ['react', 'react-dom', 'react-router-dom', 'zustand'];

const apps = ['checkout', 'profile', 'products'];
const errors: string[] = [];

for (const app of apps) {
  const pkg = JSON.parse(
    fs.readFileSync(path.join(app, 'package.json'), 'utf-8')
  );

  for (const dep of critical) {
    if (pkg.dependencies[dep] !== shellDeps[dep]) {
      errors.push(`${app}: ${dep} es ${pkg.dependencies[dep]}, shell tiene ${shellDeps[dep]}`);
    }
  }
}

if (errors.length > 0) {
  console.error('Dependencias críticas inconsistentes:');
  errors.forEach(e => console.error('  -', e));
  process.exit(1);
}
Enter fullscreen mode Exit fullscreen mode

Este script corre en CI y bloquea merges si hay drift.

Monitoreo específico para MFE

CloudWatch custom metrics para detectar cuando un remote falla:

// shell/src/monitoring/remote-monitor.ts
export function trackRemoteLoad(remoteName: string, duration: number, success: boolean) {
  fetch('/api/metrics/remote-load', {
    method: 'POST',
    body: JSON.stringify({
      remote: remoteName,
      duration,
      success,
      timestamp: Date.now(),
      userAgent: navigator.userAgent,
    }),
    keepalive: true,
  });
}
Enter fullscreen mode Exit fullscreen mode

Lambda que recibe:

import { CloudWatchClient, PutMetricDataCommand } from '@aws-sdk/client-cloudwatch';

const cw = new CloudWatchClient({});

export const handler = async (event: any) => {
  const body = JSON.parse(event.body);

  await cw.send(
    new PutMetricDataCommand({
      Namespace: 'MFE',
      MetricData: [
        {
          MetricName: 'RemoteLoadDuration',
          Value: body.duration,
          Unit: 'Milliseconds',
          Dimensions: [
            { Name: 'Remote', Value: body.remote },
            { Name: 'Success', Value: String(body.success) },
          ],
        },
        {
          MetricName: 'RemoteLoadCount',
          Value: 1,
          Unit: 'Count',
          Dimensions: [
            { Name: 'Remote', Value: body.remote },
            { Name: 'Success', Value: String(body.success) },
          ],
        },
      ],
    })
  );

  return { statusCode: 200 };
};
Enter fullscreen mode Exit fullscreen mode

Con esto, una alarma de CloudWatch puede avisar cuando un remote empieza a fallar.

Lo que NO funcionó

1. Versiones distintas de React por remote.

Intentamos que cada equipo usara su versión. Era un hack con parches manuales. Rompió en producción múltiples veces. Solución: single version policy.

2. Shared state con Redux store global.

Compartir un store Redux entre MFEs es teóricamente elegante, en la práctica genera acoplamiento y bugs. Mejor event-driven o props.

3. Routing dinámico desde remotes.

Los remotes registrando rutas en runtime sonaba flexible pero hace que el shell no sepa qué rutas existen. Decidimos: cada remote tiene su prefix, el shell controla el routing de alto nivel.

Cuándo esto NO vale la pena

  • Equipo de menos de 15 personas.
  • No hay problemas reales de deploy.
  • Los "dominios" no están claros o cambian seguido.
  • No tienes presupuesto de plataforma.

Cierre

Los micro-frontends con Module Federation son una herramienta potente para escalar equipos frontend, pero con costos reales. El setup inicial es complejo, el mantenimiento requiere disciplina, y el versionado de dependencias es un dolor permanente. Si te sientas a evaluar honestamente tu situación y aún ves beneficios, este es el patrón.

En el próximo artículo vamos al edge: Lambda@Edge para A/B testing de frontend con routing en el edge.

Top comments (0)