Micro-frontends: Module Federation e estratégias

1. Introdução aos Micro-frontends com React e Node.js

Aplicações frontend monolíticas enfrentam desafios conhecidos: equipes grandes pisando nos mesmos arquivos, deploys lentos, dificuldade de escalar o desenvolvimento e impossibilidade de usar tecnologias diferentes em partes específicas do sistema. Micro-frontends surgem como resposta a esses problemas, aplicando ao frontend os mesmos princípios de microsserviços: cada domínio de negócio (bounded context) é desenvolvido, testado e implantado de forma independente.

No ecossistema JavaScript, o Module Federation do Webpack 5 se destaca por permitir que módulos sejam compartilhados em tempo de execução sem a necessidade de iframes ou proxies complexos. Combinado com Node.js para orquestração no servidor e React para construção de interfaces, temos um stack maduro para implementar micro-frontends de forma prática e escalável.

2. Fundamentos do Module Federation no Webpack 5

O Module Federation funciona com dois papéis principais: o host (aplicação que consome módulos remotos) e o remote (aplicação que expõe módulos para consumo). A comunicação acontece via um arquivo remoteEntry.js gerado pelo Webpack, que contém o manifesto dos módulos disponíveis.

Vamos criar um micro-frontend que expõe um componente React:

// webpack.config.js (remote - micro-frontend de produtos)
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  mode: 'development',
  devServer: { port: 3001 },
  plugins: [
    new ModuleFederationPlugin({
      name: 'products',
      filename: 'remoteEntry.js',
      exposes: {
        './ProductList': './src/components/ProductList',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
      },
    }),
  ],
};
// src/components/ProductList.jsx
import React from 'react';

const ProductList = () => {
  const products = [
    { id: 1, name: 'Notebook', price: 4500 },
    { id: 2, name: 'Mouse', price: 120 },
  ];

  return (
    <ul>
      {products.map(product => (
        <li key={product.id}>
          {product.name} - R$ {product.price}
        </li>
      ))}
    </ul>
  );
};

export default ProductList;

3. Estruturando uma Aplicação Full-Stack com Micro-frontends

No host (container principal), configuramos o consumo do remote:

// webpack.config.js (host - aplicação principal)
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  mode: 'development',
  devServer: { port: 3000 },
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        products: 'products@http://localhost:3001/remoteEntry.js',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
      },
    }),
  ],
};

Para integrar com Node.js, podemos servir os bundles federados via Express:

// server.js (Node.js + Express)
const express = require('express');
const path = require('path');

const app = express();
const port = 3000;

app.use(express.static(path.join(__dirname, 'dist')));

app.get('*', (req, res) => {
  res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});

app.listen(port, () => {
  console.log(`Host rodando em http://localhost:${port}`);
});

O compartilhamento de dependências com singleton: true garante que React e ReactDOM sejam instanciados apenas uma vez, evitando conflitos de contexto.

4. Estratégias de Roteamento e Navegação

No host, utilizamos React Router com lazy loading para carregar os micro-frontends sob demanda:

// src/App.jsx (host)
import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';

const ProductList = lazy(() => import('products/ProductList'));

const App = () => {
  return (
    <BrowserRouter>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/produtos">Produtos</Link>
      </nav>

      <Suspense fallback={<div>Carregando...</div>}>
        <Routes>
          <Route path="/" element={<h1>Bem-vindo</h1>} />
          <Route path="/produtos" element={<ProductList />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
};

export default App;

Para navegação entre domínios, utilizamos eventos customizados:

// No micro-frontend de checkout
const navigateTo = (path) => {
  window.dispatchEvent(
    new CustomEvent('app-navigate', { detail: { path } })
  );
};

// No host
useEffect(() => {
  const handler = (event) => {
    navigate(event.detail.path);
  };
  window.addEventListener('app-navigate', handler);
  return () => window.removeEventListener('app-navigate', handler);
}, []);

5. Gerenciamento de Estado entre Micro-frontends

Para estado global compartilhado, o Jotai oferece uma abordagem simples e performática:

// store.js (módulo compartilhado via Module Federation)
import { atom } from 'jotai';

export const cartAtom = atom([]);
export const userAtom = atom(null);
// No micro-frontend de produtos
import { useAtom } from 'jotai';
import { cartAtom } from 'shared/store';

const ProductCard = ({ product }) => {
  const [cart, setCart] = useAtom(cartAtom);

  const addToCart = () => {
    setCart([...cart, product]);
  };

  return (
    <div>
      <h3>{product.name}</h3>
      <button onClick={addToCart}>Adicionar ao carrinho</button>
    </div>
  );
};

Para workflows mais complexos, o XState permite criar máquinas de estado centralizadas:

import { createMachine, interpret } from 'xstate';

const checkoutMachine = createMachine({
  id: 'checkout',
  initial: 'idle',
  states: {
    idle: { on: { START: 'loading' } },
    loading: { on: { SUCCESS: 'success', ERROR: 'error' } },
    success: { type: 'final' },
    error: { on: { RETRY: 'loading' } },
  },
});

export const checkoutService = interpret(checkoutMachine).start();

6. Estilização e Design System Consistente

Criamos um módulo federado de UI para compartilhar temas e componentes:

// webpack.config.js (design-system)
new ModuleFederationPlugin({
  name: 'design_system',
  exposes: {
    './Theme': './src/theme',
    './Button': './src/components/Button',
    './Card': './src/components/Card',
  },
  shared: {
    react: { singleton: true },
    'styled-components': { singleton: true },
  },
}),
// src/theme.js (design-system)
import { createGlobalStyle } from 'styled-components';

export const theme = {
  colors: {
    primary: '#0070f3',
    secondary: '#0070f3',
    background: '#ffffff',
  },
  spacing: (n) => `${n * 8}px`,
};

export const GlobalStyle = createGlobalStyle`
  body {
    margin: 0;
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto;
    background-color: ${({ theme }) => theme.colors.background};
  }
`;

O uso de CSS-in-JS com styled-components evita conflitos de escopo, pois os estilos são gerados com classes únicas em tempo de execução.

7. Deploy, Versionamento e Testes

Para CI/CD com múltiplos repositórios, cada micro-frontend tem seu próprio pipeline:

# .github/workflows/deploy-products.yml
name: Deploy Products Micro-frontend

on:
  push:
    branches: [main]
    paths:
      - 'micro-frontends/products/**'

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
      - run: npm ci
      - run: npm run build
      - run: npm test
      - uses: some-deploy-action@v1
        with:
          bucket: ${{ secrets.S3_BUCKET }}
          dist-folder: ./dist

Para testes de integração com Cypress:

// cypress/e2e/micro-frontends.cy.js
describe('Micro-frontends Integration', () => {
  it('should load ProductList from remote', () => {
    cy.visit('http://localhost:3000/produtos');
    cy.contains('Notebook').should('be.visible');
    cy.contains('Mouse').should('be.visible');
  });

  it('should share cart state between micro-frontends', () => {
    cy.visit('http://localhost:3000/produtos');
    cy.get('[data-testid="add-to-cart"]').first().click();
    cy.visit('http://localhost:3001/carrinho');
    cy.contains('Notebook').should('be.visible');
  });
});

8. Conclusão e Próximos Passos

O Module Federation oferece uma abordagem madura e nativa do Webpack para implementar micro-frontends, permitindo que equipes trabalhem de forma independente enquanto mantêm uma experiência de usuário integrada. Os principais desafios incluem o gerenciamento de versões compartilhadas, a comunicação entre domínios e a manutenção de um design system consistente.

Ferramentas complementares como Single SPA (para orquestração avançada), Piral (com seu sistema de plugins) e Nx (para monorepos com geração de código) podem potencializar ainda mais a arquitetura. Na próxima etapa da série, exploraremos como integrar esses conceitos em uma aplicação full-stack completa, combinando React no frontend com Node.js no backend e um banco de dados real.

Referências