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.

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

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.

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

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 Shellconst 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 produtosconst 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 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 — Shellimport { 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-frontendsinterface 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])    );  }}

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());  }}

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 inteiroimport { MaterialModule } from './material.module'; // tudo junto// ✅ Faça assim — só o que o micro-frontend precisaimport { 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.

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.