Facade Pattern
The facade pattern wraps a tangle of stores, HTTP services, and business logic behind a single, intention-revealing API. Components depend only on the facade, so they stay thin and declarative while the complexity lives in one well-tested place. In Angular this maps naturally onto an @Injectable service that exposes a handful of signals (or observables) for reading state and a few methods for mutating it. The result is a clean seam that makes components easier to read, test, and refactor.
Why a facade
A “smart” component often grows tentacles: it injects an HTTP client, a state store, a router, a logger, and stitches them together inside the component class. That couples the component to implementation details it should not care about. A facade collapses all of that into one dependency.
| Without facade | With facade |
|---|---|
| Component injects 4-6 collaborators | Component injects 1 facade |
| Orchestration logic lives in the component | Orchestration lives in the facade |
| Hard to unit-test (many mocks) | Easy to test (mock one facade) |
| Swapping the data layer touches every component | Swap behind the facade, components unchanged |
The component asks what it wants (“load the products”, “select this one”) and the facade decides how.
A products facade
Below, the facade owns a signal store and an HTTP service, then exposes read-only signals plus a couple of commands. Notice that components never see HttpClient or the raw writable signals.
import { Injectable, computed, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
export interface Product {
id: string;
name: string;
price: number;
}
@Injectable({ providedIn: 'root' })
export class ProductsFacade {
private readonly http = inject(HttpClient);
// Private writable state — never exposed directly.
private readonly _products = signal<Product[]>([]);
private readonly _selectedId = signal<string | null>(null);
private readonly _loading = signal(false);
// Public, read-only projections.
readonly products = this._products.asReadonly();
readonly loading = this._loading.asReadonly();
readonly selected = computed(() =>
this._products().find((p) => p.id === this._selectedId()) ?? null,
);
readonly total = computed(() =>
this._products().reduce((sum, p) => sum + p.price, 0),
);
async load(): Promise<void> {
this._loading.set(true);
try {
const data = await firstValueFrom(
this.http.get<Product[]>('/api/products'),
);
this._products.set(data);
} finally {
this._loading.set(false);
}
}
select(id: string): void {
this._selectedId.set(id);
}
}
The asReadonly() calls and computed() projections enforce a one-way data flow: components read derived signals and call methods, but cannot mutate state behind the facade’s back.
Keep facades thin. A facade is an orchestrator, not a dumping ground. If a method grows past a few lines, push the real work into the store or a domain service and let the facade delegate.
Consuming it from a component
The component is now almost pure template. It injects one thing, reads signals, and forwards user intent.
import { Component, inject } from '@angular/core';
import { ProductsFacade } from './products.facade';
@Component({
selector: 'app-product-list',
standalone: true,
template: `
@if (facade.loading()) {
<p>Loading…</p>
} @else {
<ul>
@for (p of facade.products(); track p.id) {
<li (click)="facade.select(p.id)">{{ p.name }} — {{ p.price }}</li>
}
</ul>
<p>Total: {{ facade.total() }}</p>
@if (facade.selected(); as item) {
<p>Selected: {{ item.name }}</p>
}
}
`,
})
export class ProductListComponent {
protected readonly facade = inject(ProductsFacade);
constructor() {
this.facade.load();
}
}
Output:
Loading…
Widget — 9.99
Gadget — 19.99
Gizmo — 4.5
Total: 34.48
Selected: Gadget
Testing the facade
Because all orchestration is in one place, tests target the facade directly and components get a trivially mockable dependency.
import { TestBed } from '@angular/core/testing';
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { ProductsFacade } from './products.facade';
describe('ProductsFacade', () => {
let facade: ProductsFacade;
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
facade = TestBed.inject(ProductsFacade);
http = TestBed.inject(HttpTestingController);
});
it('loads products and computes the total', async () => {
const promise = facade.load();
http.expectOne('/api/products').flush([
{ id: '1', name: 'Widget', price: 10 },
{ id: '2', name: 'Gadget', price: 5 },
]);
await promise;
expect(facade.products().length).toBe(2);
expect(facade.total()).toBe(15);
});
});
Facades over NgRx or signal stores
A facade pairs especially well with a heavier state library. Instead of leaking Store, action creators, and selector keys into every component, the facade dispatches actions and selects state on their behalf — so you can later replace NgRx with a signal store without touching a single component.
@Injectable({ providedIn: 'root' })
export class CartFacade {
private readonly store = inject(Store);
readonly items = this.store.selectSignal(selectCartItems);
readonly count = computed(() => this.items().length);
add(productId: string): void {
this.store.dispatch(cartActions.add({ productId }));
}
}
Best Practices
- Expose read-only signals or observables; keep all writable state private inside the facade.
- Provide facades in
'root'for app-wide singletons, or at a route/component level when state should be scoped and disposed with a feature. - Keep methods intention-revealing (
select,checkout,archive) rather than CRUD-shaped (setX,getY). - Let the facade delegate, not implement — heavy logic belongs in stores and domain services.
- Mock the facade when testing components, and test the facade itself with
HttpTestingControlleror a stubbed store. - Don’t share one giant “AppFacade” across the whole app; create one facade per feature/domain to avoid a god object.