Modern Angular
Facade Pattern in Angular: When Components Know Too Much
A facade earns its place when a component stops describing UI and starts coordinating API, permissions, route state, loading, errors, and commands. The Gang of Four defined Facade in 1994 as providing 'a simplified interface to a large body of code'. In Angular, the pattern got popular through NgRx: Thomas Burleson's 'NgRx + Facades' (2018) framed it as 'a simpler public interface to mask a composition of internal, more-complex, component usages'.
Start with the smell, not the pattern name
Do not start with a textbook definition of the Facade Pattern. That is not how the problem appears in a mature Angular app.
The problem appears when a component stops describing UI and starts coordinating a feature workflow. It reads route state, calls the API, checks permissions, maps loading and error states, decides whether a command is allowed, shows operational feedback, and still has to keep the template readable.
At that point, a facade has a job. It gives the page one explicit feature API to consume. It does not exist because every feature needs a class named Facade.
| Signal in review | What it usually means | Facade decision |
|---|---|---|
| Component injects 4+ services | It is coordinating feature contracts | Consider a facade |
| Template repeats permission and status checks | Business state leaked into markup | Move derived state behind the facade |
| Action handler mixes validation, API call, error, and reload | Command workflow lives in the component | Move the command to the facade |
Facade only forwards api.get() | No real complexity is hidden | Do not add the facade yet |
| Facade becomes shared by unrelated screens | It is turning into a service layer | Split by feature or route |
The component that knows too much
This example is not risky because it is long. It is risky because every new product rule has to land in the same place: route input, request state, permission logic, action handling, and UI feedback all compete inside one component.
The first version often ships because it is faster than introducing a boundary. That is fine. The review question is when the next change makes the component harder to reason about than the feature itself.
Do not copy this as the target design. It collapses load failures into null, leaves the command subscription in the component, and makes permission logic part of the page class. Those are the symptoms the facade should remove.
@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'),
});
}
}What the facade should own
A feature facade should own the feature-facing contract. For this page, that means the selected order, request state, permission-derived state, command state, reload behavior, and operational error from the approval action.
In this article, the feature workflow is the coordination problem; the facade is the implementation boundary that gives that workflow one page-facing API.
It should not own the backend contract. OrdersApi still owns HTTP details. It should not own generic authorization rules either. PermissionsService remains the source of permission data. The facade coordinates those contracts for one screen.
This keeps the component small without pretending the complexity disappeared. The coordination still exists; it has a named owner.
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'),
});
}
}The component becomes a thin page boundary
After the facade exists, the component has a narrower job: connect the selected id input to the facade and render the contract. The template still shows real states. It stops rebuilding product rules inline.
The template uses @let to name the current state and current action error once. That keeps the discriminated state readable and avoids non-null assertions in the markup.
The providers array matters. Providing the facade at the page boundary gives this route its own instance and prevents a temporary approval workflow from becoming accidental global state.
@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>
}
}A facade still needs a boundary
The failure mode is predictable: once the team likes facades, every method moves there. That produces a service with a cleaner name and the same lack of boundaries.
Reject a facade that only renames an API call. Reject a facade that is shared by unrelated features because it feels convenient. Reject a facade that makes simple template state harder to trace.
A good facade removes coordination from the component while keeping ownership clear. API services own transport. Stores own shared domain state. Adapters own translation from external contracts. The facade owns the page's feature workflow.
| Logic | Better owner | Reason |
|---|---|---|
| HTTP URL, headers, DTO parsing | OrdersApi or an adapter | The backend contract should stay outside the page workflow |
| Cross-screen cart/auth state | Store or domain service | The state outlives one page |
| Permission value exposed by backend/auth | PermissionsService | The facade should consume permission data, not invent it |
| Can this specific order be approved now? | Feature facade | It combines order state and permission state for this screen |
| Button disabled state and retry action | Component reads facade contract | The template should render decisions, not rebuild them |
Test the decision, not the DOM
A facade gives the team a cheaper test target. You can test the approval rule, command guard, reload behavior, and dependency substitution without rendering the full page.
That matters because the most expensive component tests are often trying to prove feature rules that do not need the DOM. Angular's TestBed can wire the facade through DI and substitute slow or unpredictable dependencies with controlled providers.
The example avoids fakeAsync so it stays aligned with a Vitest or zoneless-friendly direction. If a legacy suite still uses Karma/Jasmine helpers, label that as stack-specific instead of making it the new default.
This is not a replacement for component tests. It is a way to keep feature rules from being tested only through DOM assertions.
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();
});
});Reusable artifact
Facade review checklist
- Create a facade when a component coordinates multiple feature contracts, not because every page needs one.
- Keep API services responsible for backend transport and DTO details.
- Expose a small page contract: state, derived decisions, commands, and reload/error behavior.
- Provide the facade at the route or page boundary when the workflow should not become global state.
- Reject facades that only proxy API calls or grow across unrelated features.
- Test feature rules at the facade boundary before relying on full DOM tests.
Sources checked
- 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
This article is one play.
The Modern Angular Playbook collects the diagnostic, the adoption matrix, eleven plays, and the 30-day plan. Free, in English and Portuguese.
You get both PDFs by email, through the Dojo IA list.
André Ramosavailable for remote roles, UTC−3Get in touch →