Перейти к содержимому

61. Паттерн Facade

Привет! Яша здесь. Facade — один из самых полезных паттернов в Angular. Он прячет сложность NgRx/NGXS за простым API сервиса. Разберём как это делается, почему это важно и как тестировать фасады 🚀


Представь: у тебя NgRx с actions, reducers, effects, selectors. Компонент должен знать ВСЁ это.

Без Facade (компонент знает про NgRx):

// Компонент напрямую работает с NgRx
constructor(
private store: Store<AppState>,
private actions$: Actions,
) {}
ngOnInit() {
this.users$ = this.store.select(selectUsers);
this.loading$ = this.store.select(selectUsersLoading);
this.store.dispatch(UsersActions.loadUsers({ page: 1 }));
}
deleteUser(id: string) {
this.store.dispatch(UsersActions.deleteUser({ id }));
}

С Facade (компонент не знает про NgRx):

// Компонент знает только про UsersFacade
constructor(private usersFacade: UsersFacade) {}
ngOnInit() {
this.users$ = this.usersFacade.users$;
this.usersFacade.loadUsers();
}
deleteUser(id: string) {
this.usersFacade.deleteUser(id);
}

Facade — это anti-corruption layer: компонент не зависит от реализации стейт-менеджмента.


users.facade.ts
import { Injectable, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import * as UsersActions from './store/users.actions';
import * as UsersSelectors from './store/users.selectors';
import { User } from './user.model';
@Injectable({ providedIn: 'root' })
export class UsersFacade {
private store = inject(Store);
// ─── Selectors (публичные Observable) ─────────────────────────────────────
readonly users$: Observable<User[]> = this.store.select(UsersSelectors.selectAllUsers);
readonly selectedUser$: Observable<User | null> = this.store.select(UsersSelectors.selectSelectedUser);
readonly loading$: Observable<boolean> = this.store.select(UsersSelectors.selectUsersLoading);
readonly error$: Observable<string | null> = this.store.select(UsersSelectors.selectUsersError);
readonly totalCount$: Observable<number> = this.store.select(UsersSelectors.selectUsersTotalCount);
// ─── Actions (публичные методы) ───────────────────────────────────────────
loadUsers(page = 1, limit = 20): void {
this.store.dispatch(UsersActions.loadUsers({ page, limit }));
}
loadUserById(id: string): void {
this.store.dispatch(UsersActions.loadUser({ id }));
}
selectUser(id: string): void {
this.store.dispatch(UsersActions.selectUser({ id }));
}
createUser(user: Omit<User, 'id'>): void {
this.store.dispatch(UsersActions.createUser({ user }));
}
updateUser(id: string, changes: Partial<User>): void {
this.store.dispatch(UsersActions.updateUser({ id, changes }));
}
deleteUser(id: string): void {
this.store.dispatch(UsersActions.deleteUser({ id }));
}
clearSelection(): void {
this.store.dispatch(UsersActions.clearSelectedUser());
}
resetError(): void {
this.store.dispatch(UsersActions.resetUsersError());
}
}

// users-local.facade.ts — ComponentStore как Facade
import { Injectable } from '@angular/core';
import { ComponentStore } from '@ngrx/component-store';
import { Observable, switchMap, tapResponse } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { User } from './user.model';
interface UsersState {
users: User[];
loading: boolean;
error: string | null;
selectedId: string | null;
}
@Injectable()
export class UsersLocalFacade extends ComponentStore<UsersState> {
constructor(private http: HttpClient) {
super({ users: [], loading: false, error: null, selectedId: null });
}
// ─── Selectors ─────────────────────────────────────────────────────────────
readonly users$ = this.select(state => state.users);
readonly loading$ = this.select(state => state.loading);
readonly error$ = this.select(state => state.error);
readonly selectedUser$ = this.select(state =>
state.users.find(u => u.id === state.selectedId) ?? null
);
// ─── Updaters (синхронные мутации) ────────────────────────────────────────
readonly setUsers = this.updater((state, users: User[]) => ({
...state, users, loading: false, error: null,
}));
readonly addUser = this.updater((state, user: User) => ({
...state, users: [...state.users, user],
}));
readonly removeUser = this.updater((state, id: string) => ({
...state, users: state.users.filter(u => u.id !== id),
}));
// ─── Effects (асинхронные операции) ──────────────────────────────────────
readonly loadUsers = this.effect<void>(trigger$ =>
trigger$.pipe(
switchMap(() => {
this.patchState({ loading: true });
return this.http.get<User[]>('/api/users').pipe(
tapResponse(
users => this.setUsers(users),
error => this.patchState({ error: error.message, loading: false }),
)
);
})
)
);
// ─── Публичные методы (Facade API) ───────────────────────────────────────
selectUser(id: string): void {
this.patchState({ selectedId: id });
}
deleteUser(id: string): void {
this.removeUser(id);
// В реальном приложении — HTTP запрос через effect
}
}

users-signals.facade.ts
import { Injectable, signal, computed, effect } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { User } from './user.model';
@Injectable({ providedIn: 'root' })
export class UsersSignalsFacade {
// ─── Приватное состояние ──────────────────────────────────────────────────
private _users = signal<User[]>([]);
private _loading = signal(false);
private _error = signal<string | null>(null);
private _selectedId = signal<string | null>(null);
// ─── Публичное состояние (readonly) ──────────────────────────────────────
readonly users = this._users.asReadonly();
readonly loading = this._loading.asReadonly();
readonly error = this._error.asReadonly();
// ─── Вычисляемые значения ────────────────────────────────────────────────
readonly selectedUser = computed(() =>
this._users().find(u => u.id === this._selectedId()) ?? null
);
readonly totalCount = computed(() => this._users().length);
readonly activeUsers = computed(() =>
this._users().filter(u => u.status === 'active')
);
constructor(private http: HttpClient) {
// Логируем изменения (side effect)
effect(() => {
console.log('Users updated:', this._users().length, 'total');
});
}
// ─── Публичные методы ─────────────────────────────────────────────────────
async loadUsers(): Promise<void> {
this._loading.set(true);
this._error.set(null);
try {
const users = await this.http.get<User[]>('/api/users').toPromise();
this._users.set(users!);
} catch (err: unknown) {
this._error.set((err as Error).message);
} finally {
this._loading.set(false);
}
}
selectUser(id: string): void {
this._selectedId.set(id);
}
updateUser(id: string, changes: Partial<User>): void {
this._users.update(users =>
users.map(u => u.id === id ? { ...u, ...changes } : u)
);
}
deleteUser(id: string): void {
this._users.update(users => users.filter(u => u.id !== id));
}
}

users.facade.spec.ts
import { TestBed } from '@angular/core/testing';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { UsersFacade } from './users.facade';
import * as UsersActions from './store/users.actions';
import * as UsersSelectors from './store/users.selectors';
describe('UsersFacade', () => {
let facade: UsersFacade;
let store: MockStore;
const initialState = {
users: {
users: [{ id: '1', name: 'Яша', email: '[email protected]', role: 'admin' }],
loading: false,
error: null,
selectedId: null,
}
};
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
UsersFacade,
provideMockStore({ initialState }),
]
});
facade = TestBed.inject(UsersFacade);
store = TestBed.inject(MockStore);
store.overrideSelector(UsersSelectors.selectAllUsers, initialState.users.users);
});
it('должен вернуть users из store', (done) => {
facade.users$.subscribe(users => {
expect(users.length).toBe(1);
expect(users[0].name).toBe('Яша');
done();
});
});
it('должен диспатчить loadUsers action', () => {
const dispatchSpy = spyOn(store, 'dispatch');
facade.loadUsers(1, 20);
expect(dispatchSpy).toHaveBeenCalledWith(
UsersActions.loadUsers({ page: 1, limit: 20 })
);
});
it('должен диспатчить deleteUser action', () => {
const dispatchSpy = spyOn(store, 'dispatch');
facade.deleteUser('1');
expect(dispatchSpy).toHaveBeenCalledWith(
UsersActions.deleteUser({ id: '1' })
);
});
});
// В компонентах — мокируем Facade, а не Store!
@Injectable()
class MockUsersFacade {
users$ = of([{ id: '1', name: 'Test User', email: '[email protected]', role: 'user' }]);
loading$ = of(false);
error$ = of(null);
loadUsers = jasmine.createSpy('loadUsers');
deleteUser = jasmine.createSpy('deleteUser');
}

Визуализатор паттерна Facade: до и после: