This content is only available in Portuguese.

Not translated yet for this language.

Frontend

Angular Micro-frontends: Arquitetura Distribuída

Quando um app Angular cresce demais, o monolítico falha. Este guia mostra como usar Module Federation para criar uma arquitetura distribuída, dividindo o build entre equipes e eliminando conflitos de merge sem sacrificar performance.

Equipe Blueprintblog12 min
Angular Micro-frontends: Arquitetura Distribuída

Seu app Angular começou pequeno. Uns 15 componentes, duas rotas, um módulo de autenticação. O build levava segundos. Os merges eram tranquilos. A vida era boa.

Seis meses depois, o projeto tem 200 componentes. Três squads trabalhando no mesmo repositório. O ng build leva 4 minutos. Cada merge vira uma negociação diplomática. E o pior: quando o time de produtos quebra algo no checkout, o time de catálogo descobre na sexta à noite.

Esse não é um problema de código ruim. É um problema de arquitetura. Quando três equipes compartilham o mesmo build, o mesmo deploy e o mesmo bundle — qualquer mudança de uma afeta todas as outras.

Micro-frontends existem pra resolver exatamente isso.

O que são micro-frontends (sem o jargão)

Se você já ouviu falar de microsserviços no backend, micro-frontends são a mesma ideia aplicada ao frontend: em vez de um app monolítico gigante, você divide a aplicação em pedaços menores e independentes. Cada pedaço tem seu próprio repositório, seu próprio build e seu próprio deploy.

No Angular, isso significa que o módulo de produtos pode ser um app Angular separado. O módulo de pedidos, outro. O módulo de autenticação, outro. Cada um desenvolvido, testado e publicado por equipes diferentes — sem pisar no pé de ninguém.

Uma aplicação principal (o Shell) orquestra tudo: ela carrega os micro-frontends sob demanda, cuida do roteamento global e garante que o usuário final veja uma experiência única e coesa — sem perceber que por baixo existem vários apps independentes.

Os três pilares de uma arquitetura micro-frontend

Shell + Remotos + Shared = micro-frontends que funcionam como um app só pro usuário final.

🏠

Shell

A aplicação host que orquestra tudo. Cuida do roteamento global e carrega os micro-frontends dinamicamente.

📦

Remotos

Os micro-frontends independentes. Cada um é um app Angular completo, com seu próprio build e deploy.

🔗

Shared

Dependências compartilhadas entre todos — Angular core, RxJS, design system. Carregadas uma vez, usadas por todos.

Module Federation: a tecnologia que faz tudo funcionar

A mágica por trás dos micro-frontends no Angular tem nome: Module Federation. É uma funcionalidade do Webpack 5 que permite que builds diferentes compartilhem módulos em tempo de execução. Em termos práticos: o app de produtos pode importar código do app de pedidos sem que os dois tenham sido compilados juntos.

Vamos ver como isso se monta na prática. Primeiro, a configuração do Shell — o app que vai orquestrar tudo:

javascript
// webpack.config.js — configuração do Shell
const ModuleFederationPlugin = require("@module-federation/webpack");

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: "shell",
      remotes: {
        // Cada micro-frontend é registrado aqui
        "mfe-products": "mfeProducts@http://localhost:4201/remoteEntry.js",
        "mfe-orders": "mfeOrders@http://localhost:4202/remoteEntry.js"
      },
      shared: {
        // Dependências que todos compartilham — carregadas uma vez só
        "@angular/core": { singleton: true, strictVersion: true },
        "@angular/common": { singleton: true, strictVersion: true },
        "@angular/router": { singleton: true, strictVersion: true }
      }
    })
  ]
};

Agora, a configuração do micro-frontend de produtos — o app que vai ser carregado remotamente:

javascript
// webpack.config.js — configuração do micro-frontend de produtos
const ModuleFederationPlugin = require("@module-federation/webpack");

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: "mfeProducts",
      filename: "remoteEntry.js", // o ponto de entrada que o Shell vai carregar
      exposes: {
        // O que esse micro-frontend disponibiliza pro mundo
        "./ProductsModule": "./src/app/products/products.module.ts"
      },
      shared: {
        "@angular/core": { singleton: true, strictVersion: true },
        "@angular/common": { singleton: true, strictVersion: true }
      }
    })
  ]
};

O singleton: true nas dependências compartilhadas é crítico. Sem isso, cada micro-frontend carregaria sua própria cópia do Angular — duplicando código, quebrando injeção de dependências e inflando o bundle. Com singleton: true, todos compartilham a mesma instância.

O roteamento que conecta tudo

Com o Module Federation configurado, o próximo passo é conectar os micro-frontends ao roteador do Angular. É aqui que a coisa fica elegante: o Shell carrega cada micro-frontend sob demanda, quando o usuário navega pra aquela rota.

typescript
// app-routing.module.ts — Shell
import { loadRemoteModule } from '@angular-architects/module-federation';

const routes: Routes = [
  {
    path: 'products',
    loadChildren: () => loadRemoteModule({
      type: 'module',
      remoteEntry: 'http://localhost:4201/remoteEntry.js',
      exposedModule: './ProductsModule'
    }).then(m => m.ProductsModule)
  },
  {
    path: 'orders',
    loadChildren: () => loadRemoteModule({
      type: 'module',
      remoteEntry: 'http://localhost:4202/remoteEntry.js',
      exposedModule: './OrdersModule'
    }).then(m => m.OrdersModule)
  },
  { path: '', redirectTo: '/products', pathMatch: 'full' }
];

Repara: loadRemoteModule funciona como o loadChildren que você já conhece do lazy loading — mas em vez de carregar um módulo local, ele busca um módulo de outro servidor. Pro Angular Router, é transparente. Pro usuário, é invisível.

Carregamento dinâmico: não carregue o que o usuário não precisa

O roteamento lazy já resolve parte do problema de performance. Mas em apps grandes, você precisa de mais controle. Um serviço de carregamento dinâmico evita que o mesmo micro-frontend seja baixado duas vezes e lida com falhas de rede:

typescript
@Injectable({ providedIn: 'root' })
export class DynamicModuleService {
  private loadedModules = new Set<string>();

  async loadModule(
    remoteName: string,
    exposedModule: string,
    remoteEntry: string
  ) {
    const moduleKey = `${remoteName}-${exposedModule}`;

    if (this.loadedModules.has(moduleKey)) {
      return; // já carregou, não busca de novo
    }

    try {
      await loadRemoteModule({ type: 'module', remoteEntry, exposedModule });
      this.loadedModules.add(moduleKey);
    } catch (error) {
      console.error(`Falha ao carregar ${moduleKey}:`, error);
      throw error; // deixa o componente de fallback cuidar
    }
  }
}

Comunicação entre micro-frontends

Se cada micro-frontend é independente, como eles conversam entre si? O usuário faz login no módulo de autenticação — como o módulo de pedidos fica sabendo?

A resposta é um padrão que o backend já usa há anos: event bus. Um serviço central que publica e recebe eventos tipados. Nenhum micro-frontend conhece o outro diretamente — eles só conhecem os eventos.

typescript
// Tipos dos eventos — o contrato entre micro-frontends
interface EventPayloadMap {
  'USER_LOGGED_IN': { userId: string; role: string };
  'CART_UPDATED': { itemCount: number; total: number };
  'NAVIGATION_REQUESTED': { path: string; params?: any };
}

@Injectable({ providedIn: 'root' })
export class MicroFrontendEventBus {
  private bus = new Subject<{ type: string; payload: any; source: string }>();

  // Publicar evento — type-safe
  publish<T extends keyof EventPayloadMap>(
    type: T,
    payload: EventPayloadMap[T],
    source: string
  ): void {
    this.bus.next({ type, payload, source });
  }

  // Ouvir evento — type-safe
  on<T extends keyof EventPayloadMap>(
    eventType: T
  ): Observable<EventPayloadMap[T]> {
    return this.bus.pipe(
      filter(event => event.type === eventType),
      map(event => event.payload as EventPayloadMap[T])
    );
  }
}

O EventPayloadMap é o segredo. Ele funciona como um contrato: todo micro-frontend sabe quais eventos existem e qual é o formato de cada um. Se alguém muda o payload de CART_UPDATED, o TypeScript avisa em todos os lugares que consomem esse evento. Sem surpresas em runtime.

Os erros que todo mundo comete (e como evitar)

Micro-frontends resolvem problemas reais, mas criam novos se implementados sem cuidado. Esses são os três erros mais comuns — e os três eu já vi em produção.

1. Não ter fallback quando um micro-frontend falha

Se o servidor do micro-frontend de produtos cair, o que o usuário vê? Se a resposta for "uma tela branca", você tem um problema. Todo micro-frontend remoto precisa de um componente de fallback:

typescript
@Component({
  template: `
    <div class="error-fallback">
      <h3>Este módulo está temporariamente indisponível</h3>
      <p>Estamos trabalhando pra restaurar. Tente novamente em alguns minutos.</p>
      <button (click)="retry()">Tentar novamente</button>
    </div>
  `
})
export class MicroFrontendFallbackComponent {
  retry() {
    window.location.reload();
  }
}

2. Compartilhar estado global entre micro-frontends

O instinto natural é criar um store global que todos os micro-frontends acessam. Resista a esse instinto. Estado compartilhado global cria acoplamento forte — exatamente o que micro-frontends existem pra eliminar. Cada micro-frontend mantém seu próprio estado e se comunica via eventos:

typescript
@Injectable()
export class BoundedContextService {
  // Cada micro-frontend mantém seu próprio estado
  private localState = new BehaviorSubject(initialState);

  // Comunica via eventos, nunca compartilha estado diretamente
  notifyDomainEvent(event: DomainEvent): void {
    this.eventBus.publish('DOMAIN_EVENT', {
      context: this.contextName,
      event: event,
      timestamp: Date.now()
    }, this.contextName);
  }
}

3. Memory leaks no carregamento dinâmico

Quando um micro-frontend é carregado e depois descarregado, ele precisa limpar tudo atrás de si — subscriptions, referências a componentes, timers. Se não limpar, o app acumula memória a cada navegação:

typescript
@Injectable()
export class MicroFrontendLifecycleService implements OnDestroy {
  private subscriptions = new Map<string, Subscription>();
  private loadedComponents = new Map<string, ComponentRef<any>>();

  unloadMicroFrontend(name: string): void {
    // Destruir componente
    const component = this.loadedComponents.get(name);
    if (component) {
      component.destroy();
      this.loadedComponents.delete(name);
    }

    // Cancelar subscriptions
    const sub = this.subscriptions.get(name);
    if (sub) {
      sub.unsubscribe();
      this.subscriptions.delete(name);
    }
  }

  ngOnDestroy(): void {
    // Na destruição do serviço, limpa tudo
    this.loadedComponents.forEach(c => c.destroy());
    this.subscriptions.forEach(s => s.unsubscribe());
  }
}

Regra prática: se seu app fica mais lento quanto mais o usuário navega entre seções, provavelmente existe um memory leak no carregamento dinâmico. Use o Chrome DevTools → Memory → Heap Snapshot antes e depois de navegar pra identificar o vazamento.

Performance: o que otimizar primeiro

Micro-frontends podem melhorar ou piorar a performance do seu app — depende de como você configura o compartilhamento de dependências e a estratégia de carregamento.

Importe só o que você usa

typescript
// ❌ Evite — importar o Angular Material inteiro
import { MaterialModule } from './material.module'; // tudo junto

// ✅ Faça assim — só o que o micro-frontend precisa
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatInputModule } from '@angular/material/input';

@NgModule({
  imports: [MatButtonModule, MatCardModule, MatInputModule]
})
export class ProductsModule { }

Pré-carregamento inteligente

Nem todo micro-frontend deve ser carregado sob demanda. O módulo de produtos num e-commerce vai ser acessado por 90% dos usuários — faz sentido pré-carregar. O módulo de relatórios? Só durante horário comercial. Você pode criar uma estratégia de preload baseada em contexto:

typescript
@Injectable()
export class SmartPreloadingStrategy implements PreloadingStrategy {
  preload(route: Route, load: () => Observable<any>): Observable<any> {
    const priority = route.data?.['preload'];

    if (priority === 'high') return load(); // sempre pré-carrega
    if (priority === 'business-hours') {
      const hour = new Date().getHours();
      if (hour >= 9 && hour <= 17) return load();
    }

    return of(null); // carrega só quando o usuário navegar
  }
}

// Nas rotas:
const routes: Routes = [
  {
    path: 'products',
    data: { preload: 'high' }, // sempre pré-carrega
    loadChildren: () => loadRemoteModule({...})
  },
  {
    path: 'reports',
    data: { preload: 'business-hours' }, // só no horário comercial
    loadChildren: () => loadRemoteModule({...})
  }
];

Quando NÃO usar micro-frontends

Essa é a seção mais importante do artigo.

Micro-frontends adicionam complexidade real: mais repositórios pra manter, mais pipelines de CI/CD pra configurar, mais pontos de falha em produção, mais overhead de comunicação entre módulos. Essa complexidade só se justifica quando o problema que ela resolve é maior que a dor que ela cria.

Use micro-frontends quando: múltiplas equipes trabalham em domínios distintos da mesma aplicação e os conflitos de merge, build time e deploy estão atrasando entregas. Não use quando: o time é pequeno, o app é gerenciável, e o problema real é organização de código — que se resolve com lazy loading e boa modularização, sem precisar de Module Federation.

Migrando um monolito: por onde começar

Se você decidiu que micro-frontends fazem sentido pro seu contexto, não tente migrar tudo de uma vez. A abordagem que funciona é incremental: mantenha as rotas legadas funcionando enquanto adiciona as novas rotas federadas ao lado delas.

typescript
// Migração incremental — rotas legadas e federadas coexistem
@NgModule({
  imports: [RouterModule.forRoot([
    // Rota legada — ainda funciona normalmente
    {
      path: 'legacy-products',
      component: ProductsComponent
    },
    // Nova rota federada — micro-frontend independente
    {
      path: 'products',
      loadChildren: () => loadRemoteModule({
        type: 'module',
        remoteEntry: 'http://localhost:4201/remoteEntry.js',
        exposedModule: './ProductsModule'
      }).then(m => m.ProductsModule)
    }
  ])]
})
export class AppRoutingModule { }

Quando o micro-frontend de produtos estiver estável em produção, você remove a rota legada. Um domínio por vez. Sem big bang.

O que levar deste artigo

  • Micro-frontends dividem um app monolítico em apps independentes com build, deploy e repositório próprios.
  • Module Federation (Webpack 5) permite que builds separados compartilhem módulos em runtime — o Shell carrega os Remotos sob demanda.
  • Comunicação via event bus tipado — cada micro-frontend conhece os eventos, nunca o outro micro-frontend diretamente.
  • Sempre implemente fallbacks — se um micro-frontend cair, o resto do app deve continuar funcionando.
  • Não compartilhe estado global — cada micro-frontend mantém seu estado e comunica via eventos.
  • Limpe referências e subscriptions ao descarregar micro-frontends — memory leaks são o bug silencioso mais comum.
  • Micro-frontends adicionam complexidade real. Só use quando múltiplas equipes em domínios distintos justificam essa complexidade.
  • Migre incrementalmente — rotas legadas e federadas coexistem até o novo micro-frontend estar estável.

Article tags

Related articles

Get the latest articles delivered to your inbox.

Follow Us: