Modern Angular
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 review | O que costuma indicar | Decisão sobre facade |
|---|---|---|
| Componente injeta 4+ services | Ele está coordenando contratos da feature | Considere uma facade |
| Template repete checagens de permissão e status | Estado de negócio vazou para o markup | Mova estado derivado para a facade |
| Handler mistura validação, API, erro e reload | Workflow de comando mora no componente | Mova o comando para a facade |
Facade só repassa api.get() | Não há complexidade real escondida | Ainda não crie a facade |
| Facade é compartilhada por telas sem relação | Ela virou camada de service genérica | Divida 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.
@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.
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.
@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());
});
}
}@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ógica | Dono mais adequado | Motivo |
|---|---|---|
| URL HTTP, headers, parsing de DTO | OrdersApi ou adapter | O contrato com o backend deve ficar fora do workflow da página |
| Estado de carrinho/auth usado em várias telas | Store ou service de domínio | O estado vive além de uma página |
| Permissão vinda do backend/auth | PermissionsService | A facade deve consumir permissões, não inventá-las |
| Este pedido pode ser aprovado agora? | Facade da feature | Combina estado do pedido e permissão para essa tela |
| Botão desabilitado e retry | Componente lendo contrato da facade | O 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.
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
- https://angular.dev/guide/di
- https://angular.dev/guide/di/dependency-injection-providers
- https://thomasburlesonia.medium.com/ngrx-facades-better-state-management-82a04b9a1e39
- https://www.thisdot.co/blog/ngrx-facade-pattern
- https://en.wikipedia.org/wiki/Design_Patterns
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.
André Ramosdisponível para vagas remotas, UTC−3Entre em contato →