Facade Pattern no Angular: quando o componente sabe demais

Uma facade merece existir quando o componente deixa de descrever UI e começa a coordenar API, permissões, rota, loading, erros e comandos. A Gang of Four definiu Facade em 1994 como dar 'uma interface simplificada pra um corpo grande de código'. No Angular, o padrão ficou popular via NgRx: o post NgRx + Facades de Thomas Burleson (2018) enquadrou como 'uma interface pública mais simples pra mascarar a composição de usos internos mais complexos'.

Comece pelo cheiro, não pelo nome do pattern

Não comece por uma definição de livro do Facade Pattern. Não é assim que o problema aparece em um app Angular maduro.

O problema aparece quando o componente deixa de descrever UI e passa a coordenar o workflow da feature. Ele lê rota, chama API, checa permissão, mapeia loading e erro, decide se um comando pode rodar, mostra feedback operacional e ainda precisa manter o template legível.

Nesse ponto, a facade tem uma função clara. Ela dá à página uma API explícita da feature para consumir. Ela não existe porque toda feature precisa de uma classe chamada Facade.

Sinal no reviewO que costuma indicarDecisão sobre facade
Componente injeta 4+ servicesEle está coordenando contratos da featureConsidere uma facade
Template repete checagens de permissão e statusEstado de negócio vazou para o markupMova estado derivado para a facade
Handler mistura validação, API, erro e reloadWorkflow de comando mora no componenteMova o comando para a facade
Facade só repassa api.get()Não há complexidade real escondidaAinda não crie a facade
Facade é compartilhada por telas sem relaçãoEla virou camada de service genéricaDivida por feature ou rota

O componente que sabe demais

Este exemplo não é arriscado porque é longo. Ele é arriscado porque toda nova regra de produto cai no mesmo lugar: input de rota, estado de request, lógica de permissão, ação do usuário e feedback de UI competem dentro do componente.

A primeira versão muitas vezes vai para produção porque é mais rápida do que criar um limite. Tudo bem. A pergunta de review é quando a próxima mudança torna o componente mais difícil de entender do que a própria feature.

Não copie isso como desenho final. O exemplo transforma falha de carregamento em null, deixa a subscription do comando dentro do componente e coloca lógica de permissão na classe da página. Esses são os sintomas que a facade deveria remover.

Componente coordenando tudots
@Component({
  selector: 'app-order-approval-page',
  templateUrl: './order-approval-page.html',
})
export class OrderApprovalPage {
  private readonly route = inject(ActivatedRoute);
  private readonly api = inject(OrdersApi);
  private readonly permissions = inject(PermissionsService);
  private readonly toast = inject(ToastService);

  readonly orderId = toSignal(
    this.route.paramMap.pipe(map((params) => params.get('id')!)),
    { initialValue: null }
  );
  readonly canApproveOrders = toSignal(
    this.permissions.canApproveOrders$,
    { initialValue: false }
  );

  readonly order = toSignal(
    toObservable(this.orderId).pipe(
      switchMap((id) => {
        if (!id) return of(null);
        return this.api.getOrder(id);
      }),
      catchError(() => of(null))
    ),
    { initialValue: null }
  );

  readonly canApprove = computed(() =>
    this.order()?.status === 'pending' &&
    this.canApproveOrders()
  );

  approve() {
    const order = this.order();
    if (!order || !this.canApprove()) return;

    this.api.approveOrder(order.id).subscribe({
      next: () => this.toast.success('Order approved'),
      error: () => this.toast.error('Could not approve order'),
    });
  }
}

O que a facade deve assumir

Uma facade de feature deve assumir o contrato que a página consome. Nesta tela, isso significa pedido selecionado, estado de request, estado derivado de permissão, estado do comando, reload e erro operacional da ação de aprovação.

Neste artigo, o workflow da feature é o problema de coordenação; a facade é o limite de implementação que dá a esse workflow uma API para a página consumir.

Ela não deve assumir o contrato com o backend. OrdersApi continua dona dos detalhes HTTP. Ela também não deve virar dona genérica das permissões. PermissionsService continua sendo a fonte desses dados. A facade coordena esses contratos para uma tela.

Esse limite mantém o componente pequeno sem fingir que a complexidade desapareceu. A coordenação continua existindo; ela só ganha um dono claro.

Facade da featurets
type ApprovalState =
  | { status: 'idle'; order: null; error: null }
  | { status: 'loading'; order: null; error: null }
  | { status: 'ready'; order: Order; error: null }
  | { status: 'error'; order: null; error: string };

const idleState: ApprovalState = {
  status: 'idle',
  order: null,
  error: null,
};

@Injectable()
export class OrderApprovalFacade {
  private readonly api = inject(OrdersApi);
  private readonly permissions = inject(PermissionsService);
  private readonly destroyRef = inject(DestroyRef);

  private readonly orderId = signal<string | null>(null);
  private readonly reloadVersion = signal(0);

  readonly actionError = signal<string | null>(null);
  readonly saving = signal(false);

  readonly canApproveOrders = toSignal(
    this.permissions.canApproveOrders$,
    { initialValue: false }
  );

  private readonly state$ = toObservable(
    computed(() => ({
      id: this.orderId(),
      reloadVersion: this.reloadVersion(),
    }))
  ).pipe(
    switchMap(({ id }) => {
      if (!id) return of(idleState);

      return this.api.getOrder(id).pipe(
        map((order) => ({
          status: 'ready',
          order,
          error: null,
        }) satisfies ApprovalState),
        startWith({
          status: 'loading',
          order: null,
          error: null,
        } satisfies ApprovalState),
        catchError(() => of({
          status: 'error',
          order: null,
          error: 'Could not load order',
        } satisfies ApprovalState))
      );
    }),
    shareReplay({ bufferSize: 1, refCount: true })
  );

  readonly state = toSignal(this.state$, { initialValue: idleState });

  readonly canApprove = computed(() => {
    const state = this.state();
    return state.status === 'ready' &&
      state.order.status === 'pending' &&
      this.canApproveOrders();
  });

  selectOrder(id: string) {
    this.orderId.set(id);
    this.actionError.set(null);
  }

  reload() {
    this.reloadVersion.update((value) => value + 1);
  }

  approve() {
    const state = this.state();
    if (state.status !== 'ready' || !this.canApprove() || this.saving()) return;

    this.saving.set(true);
    this.actionError.set(null);

    this.api.approveOrder(state.order.id).pipe(
      takeUntilDestroyed(this.destroyRef),
      finalize(() => this.saving.set(false))
    ).subscribe({
      next: () => this.reload(),
      error: () => this.actionError.set('Could not approve order'),
    });
  }
}

O componente vira uma página fina

Depois que a facade existe, o componente tem um trabalho mais estreito: conectar o input do pedido à facade e renderizar o contrato. O template ainda mostra estados reais. Ele para de reconstruir regras de produto inline.

O template usa @let para nomear o estado atual e o erro atual da ação uma vez. Isso mantém o estado discriminado legível e evita non-null assertions no markup.

O providers importa. Fornecer a facade no limite da página dá a essa rota sua própria instância e evita que um workflow temporário de aprovação vire estado global por acidente.

Limite da páginats
@Component({
  selector: 'app-order-approval-page',
  providers: [OrderApprovalFacade],
  templateUrl: './order-approval-page.html',
})
export class OrderApprovalPage {
  readonly facade = inject(OrderApprovalFacade);
  readonly orderId = input.required<string>();

  constructor() {
    effect(() => {
      this.facade.selectOrder(this.orderId());
    });
  }
}
Contrato do templatehtml
@let state = facade.state();
@let actionError = facade.actionError();

@switch (state.status) {
  @case ('loading') {
    <app-order-approval-skeleton />
  }
  @case ('error') {
    <app-inline-error
      [message]="state.error"
      (retry)="facade.reload()"
    />
  }
  @case ('ready') {
    <app-order-summary [order]="state.order" />

    @if (actionError; as message) {
      <app-inline-error [message]="message" />
    }

    <button
      type="button"
      [disabled]="!facade.canApprove() || facade.saving()"
      (click)="facade.approve()"
    >
      Approve order
    </button>
  }
}

Facade ainda precisa de limite

O modo de falha é previsível: quando o time gosta de facades, todo método começa a ir para lá. O resultado é um service com nome melhor e a mesma falta de limites.

Rejeite uma facade que só renomeia uma chamada de API. Rejeite uma facade compartilhada por features sem relação só porque parece conveniente. Rejeite uma facade que torna estado simples de template mais difícil de rastrear.

Uma boa facade tira coordenação do componente sem misturar responsabilidades. Services de API cuidam do transporte. Stores cuidam de estado de domínio compartilhado. Adapters traduzem contratos externos. A facade cuida do workflow da feature naquela página.

LógicaDono mais adequadoMotivo
URL HTTP, headers, parsing de DTOOrdersApi ou adapterO contrato com o backend deve ficar fora do workflow da página
Estado de carrinho/auth usado em várias telasStore ou service de domínioO estado vive além de uma página
Permissão vinda do backend/authPermissionsServiceA facade deve consumir permissões, não inventá-las
Este pedido pode ser aprovado agora?Facade da featureCombina estado do pedido e permissão para essa tela
Botão desabilitado e retryComponente lendo contrato da facadeO template deve renderizar decisões, não reconstruí-las

Teste a decisão, não o DOM

Uma facade dá ao time um alvo de teste mais barato. Você consegue testar regra de aprovação, guarda de comando, reload e substituição de dependências sem renderizar a página inteira.

Isso importa porque os testes de componente mais caros muitas vezes estão tentando provar regras de feature que não precisam do DOM. O TestBed consegue montar a facade via DI e trocar dependências lentas ou imprevisíveis por providers controlados.

O exemplo evita fakeAsync para ficar alinhado com uma direção mais compatível com Vitest e zoneless. Se uma suíte legada ainda usa helpers de Karma/Jasmine, marque isso como específico daquela stack em vez de virar default novo.

Isso não substitui testes de componente. É um jeito de impedir que regras de feature sejam testadas apenas por asserções de DOM.

Teste da regra da facadets
describe('OrderApprovalFacade', () => {
  const order: Order = {
    id: 'A-100',
    status: 'pending',
    total: 420,
  };

  const api = {
    getOrder: vi.fn(() => of(order)),
    approveOrder: vi.fn(() => of(void 0)),
  };

  beforeEach(() => {
    api.getOrder.mockClear();
    api.approveOrder.mockClear();

    TestBed.configureTestingModule({
      providers: [
        OrderApprovalFacade,
        { provide: OrdersApi, useValue: api },
        {
          provide: PermissionsService,
          useValue: { canApproveOrders$: of(false) },
        },
      ],
    });
  });

  it('does not approve without permission', async () => {
    const facade = TestBed.inject(OrderApprovalFacade);

    facade.selectOrder('A-100');

    await TestBed.runInInjectionContext(() =>
      firstValueFrom(
        toObservable(facade.state).pipe(
          filter((state) => state.status === 'ready'),
          take(1)
        )
      )
    );

    expect(facade.state().status).toBe('ready');
    expect(facade.canApprove()).toBe(false);
    facade.approve();

    expect(api.approveOrder).not.toHaveBeenCalled();
  });
});

Artefato para reaproveitar

Checklist de review para facade

  • Crie uma facade quando o componente coordena vários contratos da feature, não porque toda página precisa de uma.
  • Mantenha services de API responsáveis por transporte e detalhes de DTO.
  • Exponha um contrato pequeno para a página: estado, decisões derivadas, comandos, reload e erro.
  • Forneça a facade na rota ou página quando o workflow não deve virar estado global.
  • Rejeite facades que só fazem proxy de API ou crescem por features sem relação.
  • Teste regras da feature no limite da facade antes de depender só de testes de DOM.

Fontes consultadas

Modern Angular Playbook

Este artigo é um play.

O Playbook do Angular Moderno reúne o diagnóstico, a matriz de adoção, onze jogadas e o plano de 30 dias. Grátis, em inglês e português.

Você recebe os dois PDFs por email, pela lista do Dojo IA.

Abrir playbook →

André Ramosdisponível para vagas remotas, UTC−3Entre em contato →

Leia também

Guia · 9 min

Adapter Pattern no Angular: isolando APIs e bibliotecas browser-only

Um guia de produção sobre Adapter Pattern no Angular: onde traduzir contratos externos, como manter componentes limpos e quando um wrapper não compensa.

Ler artigo →

Guia · 10 min

Strategy Pattern no Angular: trocando regras sem espalhar switches

Um guia de produção sobre Strategy Pattern no Angular: quando um switch vira fronteira, como selecionar implementações com DI e como evitar indireção decorativa.

Ler artigo →

Guia · 7 min

Standalone sem transformar NgModules em vilões

Uma regra prática para usar componentes standalone, providers por rota e NgModules remanescentes em aplicações Angular maduras.

Ler artigo →