Распутывание Angular AuthService: шлюз, хранилище, фасад и порты
Разделить перегруженный Angular AuthService на гейтвей, хранилище сигналов, порт хранения и фасад, включив обновление токена однократного использования, интерцептор с ограниченной областью действия и тестируемые защитники.
Источники: Clean Architecture in Angular: Refactoring the AuthService with Ports & Adapters
Почти в каждом приложении на Angular есть AuthService, и во многих проектах это файл, к которому никто не хочет притрагиваться. Он объединяет в себе HTTP-запросы, хранение токенов, реактивное состояние, логику обновления и навигацию, поэтому изменение любого из этих элементов требует просмотра всего кода. В этой статье рассматривается процесс перестройки этого сервиса в небольшие слои с однонаправленными зависимостями, а также показаны две реальные проблемы, возникшие в результате новой структуры: сбой при обновлении, приводящий к тому, что выход из системы не происходит, и интерцептор, просачивающий токены-переносчики данных в API сторонних сервисов. Вы получите шаблон, который можно применить к собственным механизмам аутентификации, а также четкое понимание ситуаций, когда создание дополнительных файлов не оправдано.
Как возникает «бог-объект» AuthService
Никто намеренно не создаёт сервис аутентификации из 300 строк кода. Всё начинается с метода login(), который вызывает один конечный пункт. Затем требуется где-то хранить токен, поэтому появляется соответствующее поле. Чтобы токен оставался доступным после перезагрузки, добавляется localStorage. Далее появляются токены обновления, затем BehaviorSubject, чтобы компоненты могли реагировать на состояние входа, а также router.navigate('/login') при выходе из системы, поскольку казалось, что лучшего места для этого нет. Через полтора года класс начинает импортировать Router, HttpClient и localStorage, и чтобы изменить стратегию хранения токенов, необходимо понимать весь файл.
Краткая версия этой исходной точки выглядит следующим образом. Обратите внимание, сколько не связанных между собой задач используют один и тот же метод tap в функции login(): обновление поля, запись двух ключей хранения, отправка информации о состоянии и навигация.
@Injectable({ providedIn: 'root' })
export class AuthService {
private token: string | null = localStorage.getItem('token');
public isLoggedIn$ = new BehaviorSubject(!!this.token);
constructor(
private http: HttpClient,
private router: Router,
) {}
login(username: string, password: string) {
return this.http
.post<any>('https://api.example.com/auth/login', { username, password })
.pipe(
tap((res) => {
this.token = res.accessToken;
localStorage.setItem('token', res.accessToken);
localStorage.setItem('refresh', res.refreshToken);
this.isLoggedIn$.next(true);
this.router.navigate(['/dashboard']);
}),
);
}
// ...and 250 more lines
}
Ничто из этого технически не сломано, и в этом-то и кроется ловушка. Всё работает до тех пор, пока не изменится хотя бы одно требование, после чего каждая задача оказывается переплетённой со всеми остальными. Далее в этой статье рассматривается процесс перестройки функции аутентификации в небольшом проекте Angular 22. Получившаяся структура не является чем-то новым, и у каждого элемента есть общеизвестное название, но применение её к конкретному коду с реальными ошибками делает объяснение более понятным, чем диаграмма.
Целевая структура
Один класс превращается в несколько специализированных файлов, сгруппированных в общую папку core и отдельную папку для функции аутентификации.
src/app/
core/
api-config.ts ← injectable base URL
auth-interceptor.ts ← cross-cutting: attach token, retry on 401
auth/
auth-models.ts ← types
auth-api.ts ← HTTP transport, nothing else
auth-store.ts ← state, and only state
auth-storage.ts ← storage port + adapters
auth-service.ts ← orchestration (the facade)
auth-guard.ts ← route-level policy
login/
dashboard/
Использование семи небольших файлов вместо одного большого имеет смысл лишь тогда, когда каждый файл легче понять по отдельности, чем оригинальный документ в целом. Практическим критерием является возможность описать функцию каждого файла одним предложением. Здесь это возможно, а диаграмма зависимостей достаточно проста, чтобы нарисовать её за несколько строк:
auth-interceptor → auth-service → auth-api (talks to the network)
(cross-cutting) (facade) → auth-store (holds the truth)
→ auth-models (describes the shapes)
Каждая стрелка указывает вправо. Именно это правило — то, что зависимости текут в одном направлении без циклов, — позволяет структуре оставаться целостной. Каждый из разделов ниже является следствием этого правила.
Ворота: всё, что связано с сетью, в одном месте
Самый нижний уровень оборачивает HTTP-конечные точки и не выполняет ничего другого. Каждый метод соответствует своим аргументам определённому типу запроса и возвращает полученный результат.
@Service()
export class AuthApi {
private readonly httpClient = inject(HttpClient);
private readonly baseUrl = inject(API_BASE_URL);
public login(request: LoginInput) {
return this.httpClient.post<AuthResponse>(`${this.baseUrl}/auth/login`, request);
}
public refresh(refreshToken: string, expiresInMins: number) {
return this.httpClient.post<RefreshResponse>(`${this.baseUrl}/auth/refresh`, {
refreshToken,
expiresInMins,
});
}
public me() {
return this.httpClient.get<User>(`${this.baseUrl}/auth/me`);
}
}
Это шаблон Gateway из книги Мартина Фаулера Patterns of Enterprise Application Architecture. Его часто называют хранилищем (repository), однако термин «хранилище» подразумевает абстракцию вида коллекции, с которой можно выполнять запросы. Типизированный обёрток вокруг удалённых вызовов более точно описывается как шлюз (gateway).
Преимущество заключается в изоляции знаний о протоколе. Если конечная точка входа изменится с /auth/login на /v2/sessions, изменения касаются только этого класса; остальная часть приложения никогда не будет обрабатывать этот URL.
То, чего нет, имеет такое же значение, как и то, что есть: отсутствует состояние, нет localStorage, нет Router, а обработка ошибок ограничивается тем, что предоставляет сам HTTP. Каждый метод просто преобразует свои аргументы в объект Observable, что позволяет легко заменить этот класс на фиктивный во время тестирования.
Основной URL вводится через токен инъекции, а не задаётся явно:
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL');
Токен для одной строки может выглядеть как что-то сложное. На самом деле это не так, и в разделе с интерцепторами объясняется, почему он становится одной из самых важных частей реализации.
Примечание к декоратору: в примерах используется @Service() согласно спецификации проекта. Если ваша версия Angular его не поддерживает, @Injectable({ providedIn: 'root' }) выполняет ту же функцию; ознакомьтесь с документацией для вашей версии.
Хранилище: единый владелец состояния сессии
Хранилище содержит информацию сессии и является единственным элементом, которому разрешено её изменять. Оно хранит токен доступа, токен обновления и данные пользователя в формате сигналов.
@Service()
export class AuthStore {
private readonly storage = inject(AUTH_STORAGE);
private readonly accessTokenState = signal<string | null>(null);
private readonly refreshTokenState = signal<string | null>(null);
private readonly userState = signal<User | null>(null);
readonly accessToken = this.accessTokenState.asReadonly();
readonly refreshToken = this.refreshTokenState.asReadonly();
readonly user = this.userState.asReadonly();
readonly isAuthenticated = computed(() => this.accessTokenState() !== null);
public setTokens(access: string, refresh: string): void {
this.accessTokenState.set(access);
this.refreshTokenState.set(refresh);
this.persist();
}
public clear(): void {
this.accessTokenState.set(null);
this.refreshTokenState.set(null);
this.userState.set(null);
this.storage.removeItem(STORAGE_KEY);
}
// ...
}
Самый важный момент легко упустить: каждый записываемый сигнал является приватным, а каждый публичный сигнал предоставляется через метод asReadonly().
Это разделение команд и запросов обеспечивается системой типов. Любой может читать данные; запись происходит только через методы, названные в соответствии с целью действия, такие как setTokens и clear. Компонент не может напрямую устанавливать токен. Это не просто рекомендуемая практика — это невозможно, поскольку тип Signal<T> с режимом только для чтения не имеет метода set().
Положительные последствия проявляются во время отладки. Вопрос «Кто изменил этот состояние?» больше не требует дополнительных исследований, поскольку изменить состояние сессии могут только несколько методов в одном файле. Сравните это с публичным объектом BehaviorSubject, где любой из десятков компонентов мог вызвать метод next().
Магазин также является единственным компонентом, взаимодействующим с сохранением данных, и он рассматривает всё, что считывается обратно, как ненадежный ввод.
private restore(): void {
const raw = this.storage.getItem(STORAGE_KEY);
if (!raw) return;
// Storage is user-writable, so treat anything in it as untrusted input.
try {
const session = JSON.parse(raw) as Partial<PersistedSession>;
if (typeof session.accessToken !== 'string' || typeof session.refreshToken !== 'string') {
this.storage.removeItem(STORAGE_KEY);
return;
}
this.accessTokenState.set(session.accessToken);
this.refreshTokenState.set(session.refreshToken);
this.userState.set(session.user ?? null);
} catch {
this.storage.removeItem(STORAGE_KEY);
}
}
localStorage по сути представляет собой текстовое поле, которое может редактироваться пользователем, и расширения браузера или старые версии собственного приложения могут оставлять в нём неожиданные данные. Использование JSON.parse без блока try/catch может привести к сбою при запуске приложения, причём строгий режим TypeScript не выдаст предупреждения, поскольку JSON.parse возвращает тип any. Проверка структуры данных и удаление записи, если она не соответствует ожидаемой, позволяет избежать ситуации, когда повреждённые сессионные данные выведут пользователя из системы вместо того, чтобы заставить его смотреть на пустой экран.
Интерфейс хранилища: возможность замены механизма сохранения данных
Зависимость AUTH_STORAGE в хранилище — это не сам localStorage. Это интерфейс с токеном инъекции, при котором стандартный фабрикатор выбирает соответствующую реализацию.
export interface AuthStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
export const AUTH_STORAGE = new InjectionToken<AuthStorage>('AUTH_STORAGE', {
providedIn: 'root',
factory: () => (typeof localStorage === 'undefined' ? new MemoryStorage() : localStorage),
});
Это суть концепции «портов и адаптеров» (шестиугольная архитектура) в её основных чертах. Интерфейс AuthStorage определяет порт. Реальное хранилище браузера подключается к нему как один из адаптеров, а класс MemoryStorage для работы в памяти на несколько строк также подключается как ещё один адаптер.
Порт решает три конкретные проблемы:
- В тестах используется реальное хранилище. Инъекция
MemoryStorageпозволяет проверять реальное поведение сохранения данных, включая запись, перезагрузку и восстановление, без необходимости очищения глобального состояния между тест-кейсами.
localStorage отсутствует на сервере, поэтому система автоматически переходит на хранение в оперативной памяти.localStorage позволяет любому скрипту, выполняющемуся на странице, его прочитать, что представляет собой реальную угрозу типа XSS. Надежным решением является использование куки с атрибутом httpOnly, задаваемой бэкендом. Благодаря тому, что хранение происходит через определенный порт, такая замена требуется только для одного адаптера, вместо того чтобы затрагивать весь кодовый базис; до момента ее выполнения риск остается в одном файле с комментарием, объясняющим ситуацию.Третий пункт носит общий характер. Хорошая архитектура не превращает плохое решение в хорошее; она делает такие решения локальными, видимыми и легко откатываемыми.
Фасад: оркестрация без прямого управления
AuthService остаётся, но выполняет гораздо меньше функций. Он координирует работу шлюза и хранилища, но сам ничего не управляет.
@Service()
export class AuthService {
private readonly authApi = inject(AuthApi);
private readonly store = inject(AuthStore);
readonly accessToken = this.store.accessToken;
readonly isAuthenticated = this.store.isAuthenticated;
readonly user = this.store.user;
public login(username: string, password: string): Observable<AuthResponse> {
return this.authApi.login({ username, password, expiresInMins: TOKEN_LIFETIME_MINUTES }).pipe(
tap((response) => {
this.store.setTokens(response.accessToken, response.refreshToken);
this.store.setUser({ /* ...map response to User... */ });
}),
);
}
public logout(): void {
this.store.clear();
}
}
Это фасад по принципу «Банда четырёх», который также выступает в роли посредника: он — единственный слой, который знает о существовании как шлюза, так и хранилища.
Повторная экспортация сигналов хранилища из фасада — это небольшая деталь с большим эффектом: компоненты зависят только от AuthService, и они даже не подозревают о существовании отдельного AuthStore. Это превращает хранилище в деталь реализации функции, поэтому позже его можно заменить на NgRx, и ни один компонент не потребует изменений.
Обратите внимание, что Router по-прежнему отсутствует. Фасад не занимается навигацией, поскольку навигация относится к области представления. Страница входа должна перейти по адресу returnUrl, кнопка выхода с панели управления — по адресу /login, а диалог об истечении срока сессии вообще может не осуществлять никакой навигации. Вызов router.navigate() внутри функции logout() принудительно заставил бы каждого вызывающего компонент соблюдать один и тот же подход. Вместо этого решение принимает сам вызывающий компонент:
protected logout(): void {
this.auth.logout();
void this.router.navigateByUrl('/login');
}
Цена этого решения — две строки кода в компоненте. Взамен AuthService можно вызывать из интерцепторов, защитных механизмов и тестов без вызова перенаправления, которого никто не просил.
Объединение одновременных обновлений в один запрос
Самым убедительным аргументом в пользу использования фасада является проблема, которую трудно решить каким-либо другим способом. Панель управления отправляет восемь запросов одновременно. Токен доступа истек полминуты назад, поэтому все восемь запросов практически одновременно возвращают ошибку 401. Наивный интерцептор реагирует на это, запуская восемь запросов на обновление токена. Многие бэкенды заменяют токен обновления каждый раз, когда он используется, поэтому к моменту прихода последующих запросов токен, который они содержат, уже утратил силу, и пользователь выходит из страницы, которую открыл всего несколько секунд назад.
Решением является метод single-flight, также называемый объединением запросов или предотвращением эффекта «грозового стада»: первый запрос отправляет команду на обновление токена, а все остальные запросы присоединяются к уже выполняющемуся запросу. Аналогичная концепция в настройках Axios рассматривается в статье почему одновременные ошибки 401 приводят к выходу пользователей.
public refresh(): Observable<RefreshResponse> {
const refreshToken = this.store.refreshToken();
if (!refreshToken) {
return throwError(() => new Error('No refresh token available'));
}
this.refreshRequest$ ??= this.authApi.refresh(refreshToken, TOKEN_LIFETIME_MINUTES).pipe(
tap((response) => this.store.setTokens(response.accessToken, response.refreshToken)),
finalize(() => (this.refreshRequest$ = undefined)),
shareReplay({ bufferSize: 1, refCount: true }),
);
return this.refreshRequest$;
}
Эти три оператора должны выполняться в этом порядке по трём основным причинам:
tapхранит новые токены. Поскольку он находится доshareReplay, он выполняется один раз за каждую совместную обработку, а не один раз на подписчика. Если его поместить послеshareReplay, он будет перезаписывать хранилище для каждого из восьми вызывающих элементов.finalizeсбрасывает значение кэшируемого поля после завершения совместной обработки запроса или возникновения ошибок. Без него при истечении срока действия токена будет воспроизводиться старый, уже использованный ответ вместо того, чтобы инициировать новый вызов.shareReplay({ bufferSize: 1, refCount: true })отправляет один HTTP-запрос всем подписчикам и прекращает его обработку после того, как уйдёт последний подписчик.
Именно операция присваивания ??= обеспечивает режим однократного выполнения: только тот вызывающий код, у которого поле оказывается пустым, формирует запрос, и все последующие вызовы получают один и тот же результат. В итоге происходит один сетевой запрос и одна замена токена.
Синхронное бросание исключения без выхода из аккаунта
Использование метода throwError в функции обновления — это не стилистический выбор. Более ранняя версия блока защиты выглядела так:
if (!refreshToken) throw new Error('No refresh token available');
Этот код кажется понятным, но на самом деле содержит ошибку. Именно границы слоев сделали эту ошибку заметной. Интерцептор обрабатывает функцию refresh() следующим образом:
return auth.refresh().pipe(
switchMap(/* retry the original request */),
catchError((refreshError) => {
auth.logout();
return throwError(() => refreshError);
}),
);
Синхронный вызов throw внутри метода refresh() происходит во время оценки выражения auth.refresh(), ещё до того, как будет вызван метод .pipe(). Оператор catchError так и не получает возможности увидеть ошибку, поэтому метод auth.logout() никогда не выполняется. Единственная ситуация, для которой была написана защитная клозула — отсутствие токена обновления — именно та ситуация, при которой пользователь остается в полупроверенном состоянии без каких-либо уведомлений.
Возврат значения throwError(() => ...) превращает сбой в уведомление об ошибке в потоке, благодаря чему раздел интерцептора, отвечающий за выход из системы, выполняется как предусмотрено. Как общее правило, метод, сигнатура которого возвращает объект Observable, должен сообщать о сбоях через этот объект, а не путем выбрасывания исключений.
Архитектурный урок заключается в том, что такая ошибка легко скрывается в классе из 300 строк, где смешаны обработка HTTP-запросов, состояния и ошибок, поскольку ничто не заставляет авторов определить, что именно обещает та или иная методика своим вызывающим элементам. Когда refresh() превращается в публичный метод-фасад с одной четкой функцией, возникает ясный вопрос: «Возвращает ли он поток с ошибкой или выбрасывает исключение?», а вместе с ним — и тест на это:
it('emits an error stream instead of throwing when there is no refresh token', () => {
const onError = vi.fn();
// A synchronous throw here would bypass the interceptor's catchError/logout branch.
expect(() => service.refresh().subscribe({ error: onError })).not.toThrow();
expect(onError).toHaveBeenCalledOnce();
});
Слои архитектуры не предотвращают ошибки. Они лишь дают им место, где они становятся видимыми, и в этом заключается основная польза таких слоев.
Интерцептор: правила кросс-каттинга и токен с ограниченным диапазоном действия
Интерцептор применяет правила аутентификации ко всем исходящим запросам: он добавляет токен-переносчик, а при коде ошибки 401 обновляет токен и пытается выполнить запрос снова.
const UNAUTHENTICATED_PATHS = ['/auth/login', '/auth/refresh'];
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const baseUrl = inject(API_BASE_URL);
if (!isOurApi(req, baseUrl) || isUnauthenticatedRequest(req)) {
return next(req);
}
const auth = inject(AuthService);
const accessToken = auth.accessToken();
const authReq = accessToken ? withBearer(req, accessToken) : req;
return next(authReq).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status !== 401) {
return throwError(() => error);
}
return auth.refresh().pipe(
switchMap(() => {
const newToken = auth.accessToken();
if (!newToken) {
return throwError(() => new Error('Refresh succeeded without token'));
}
return next(withBearer(req, newToken));
}),
catchError((refreshError) => {
auth.logout();
return throwError(() => refreshError);
}),
);
}),
);
};
Цепочка интерцепторов Angular представляет собой прямую реализацию принципа цепочки ответственности: каждая функция интерцептора обрабатывает запрос по мере его прохождения. Что ещё важнее, правила, которые она соблюдает (прикрепление учетных данных и рассмотрение кода ошибки 401 как сигнала к обновлению данных и повторной попытке), применяются ко всему интерфейсу API, что делает её типичным примером междисциплинарной проблемы. Она не должна находиться внутри отдельных компонентов или сервисов. Без интерцептора каждый метод сервиса, обращающийся к защищенному эндпоинту, вынужден был бы повторять одни и те же операции с токеном и пытаться выполнить запрос заново, и в тот момент, когда логика повторных попыток незаметно ошибется в одной из тридцати копий кода, появится баг, который проявляется раз в неделю в продакшене, но никогда — в режиме разработки. Для более полного описания способов обработки неудачных запросов в Angular см. документацию по проектированию для обработки неудачных запросов в Angular.
Теперь внимательно посмотрите на клозулу-защитник в верхней части функции:
if (!isOurApi(req, baseUrl) || isUnauthenticatedRequest(req)) {
return next(req);
}
Именно здесь пригождается введённый параметр API_BASE_URL. Он решает две серьёзные проблемы:
- Утечка токена к другим источникам. Интерцептор без проверки URL прикрепляет токен доступа пользователя ко всем выходящим запросам, включая загрузку данных в объектное хранилище, обращения к поставщикам карт и точкам аналитики. Это приводит к передаче учетных данных независимым сторонам лишь из-за чрезмерной активности интерцептора.
- Запросы на обновление с истекшим токеном. Без списка исключений запрос на обновление также отправляется с устаревшим токеном доступа. Разговорный бэкенд может его проигнорировать; строгий же возвращает ошибку 401, что может привести к циклу обновлений.
Оба решения представляют собой однострочные действия, как только базовый URL становится доступным для использования. Без него у интерцептора нет надежного способа определить, какие запросы принадлежат вам.
Стоит обратить внимание еще на один момент: попытка повторного отправления запроса происходит с помощью next(...), а не путем повторного входа в цепочку интерцепторов с начала. Если запрос после повторной попытки также возвращает код 401, ошибка передается вызывающему коду один раз и останавливается, поэтому интерцептор не может входить в бесконечный цикл.
Защитные механизмы: контроль доступа к маршрутам как часть политики
Последний уровень отвечает на один вопрос у роутера: можно ли продолжить эту навигацию?
export const authGuard: CanActivateFn = (_route, state) => {
const store = inject(AuthStore);
const router = inject(Router);
if (store.isAuthenticated()) {
return true;
}
return router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } });
};
export const guestGuard: CanActivateFn = () => {
const store = inject(AuthStore);
const router = inject(Router);
return store.isAuthenticated() ? router.createUrlTree(['/dashboard']) : true;
};
С точки зрения безопасности, механизм контроля представляет собой точку применения правил: решение о доступе принимается исключительно в одном месте. Без него проверки авторизации часто попадают в конструкторы компонентов, забываются при добавлении маршрутов под давлением сроков, в результате чего экраны на короткое время отображают интерфейс, предназначенный только для авторизованных пользователей, прежде чем произойти перенаправление.
Возвращение объекта UrlTree вместо императивного навигирования сделано умышленно. Маршрутизатор отказывается от попытки навигации и выполняет перенаправление за один шаг, поэтому маршрут с контролем никогда не появляется на экране. Второй механизм контроля, guestGuard, выполняет противоположную функцию: он не позволяет авторизованным пользователям попадать на страницу входа.
Затем эти механизмы контроля привязываются к маршрутам:
export const routes: Routes = [
{
path: 'login',
canActivate: [guestGuard],
loadComponent: () => import('./login/login').then((m) => m.Login),
},
{
path: 'dashboard',
canActivate: [authGuard],
loadComponent: () => import('./dashboard/dashboard').then((m) => m.Dashboard),
},
{ path: '', pathMatch: 'full', redirectTo: 'dashboard' },
{ path: '**', redirectTo: 'dashboard' },
];
Параметр запроса returnUrl, устанавливаемый компонентом authGuard, доходит до компонента входа без какого-либо ручного кода с использованием ActivatedRoute. Благодаря функции provideRouter(routes, withComponentInputBinding()) параметры запроса привязываются к соответствующим входным сигналам:
readonly returnUrl = input('/dashboard');
// ...
this.auth.login(username, password).subscribe({
next: () => this.router.navigateByUrl(this.returnUrl()),
error: () => { /* ... */ },
});
Один из показательных моментов проекта: ещё до появления этого защитного компонента хранилище уже предоставляло свойство isAuthenticated — вычисляемый сигнал, который ничего не читал. Такое ненужное состояние является признаком незавершённой архитектуры. Это не безвредно; это обещание, которое приложение ещё не сдержало.
Что дают слои при тестировании
Самым очевидным преимуществом является минимальная необходимость в настройках для тестирования. Не требуются специальные фикстуры для компонентов, очистка localStorage и ручное создание имитаций HttpClient. Приведённая ниже конфигурация предоставляет вспомогательный движок тестирования HTTP для Angular и заменяет ровно два параметра — базовый URL и порт хранилища.
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: API_BASE_URL, useValue: BASE_URL },
{ provide: AUTH_STORAGE, useValue: new MemoryStorage() },
],
});
service = TestBed.inject(AuthService);
store = TestBed.inject(AuthStore);
http = TestBed.inject(HttpTestingController);
});
it('shares one in-flight request across concurrent refresh calls', () => {
store.setTokens('access-1', 'refresh-1');
service.refresh().subscribe();
service.refresh().subscribe();
const request = http.expectOne(`${BASE_URL}/auth/refresh`);
request.flush({ accessToken: 'access-2', refreshToken: 'refresh-2' });
expect(store.accessToken()).toBe('access-2');
});
В тесте типа single-flight функция refresh() вызывается дважды, после чего используется метод http.expectOne, который возвращает ошибку, если было отправлено более одного соответствующего запроса. Это позволяет за несколько строк проверить гарантию объединения запросов, причём такая проверка будет продолжаться даже после того, как кто-то изменит реализацию refresh() год спустя. Весь набор инструментов для тестирования состоит из двух внедрённых параметров, именно для этого и предназначались эти порты.
Чем этот дизайн не является
Точное определение границ паттерна настолько же важно, как и его название.
Это не Redux и не NgRx. Здесь нет действий, редьюсеров, эффектов или цикла dispatch. AuthStore — это инкапсулированное состояние с методами, названными в соответствии с целью их использования. Если вам нужна отладка с возможностью «путешествия во времени», координация эффектов между различными функциями или сериализованный журнал действий для повторного воспроизведения, переходите на NgRx; бэкенд-интерфейс позволяет компонентам не замечать смену фреймворка.
Это не полная архитектура Clean Architecture. Здесь нет классов случаев использования, а модель домена не разделена от формата передачи данных. Тип User в файле auth-models.ts представляет собой просто структуру ответа API. Строгий подход Clean Architecture предусматривал бы определение отдельных типов и преобразование между ними. При наличии лишь нескольких полей такой слой маппинга приносит больше затрат, чем пользы, но когда структура API и модель домена начинают отличаться, именно здесь следует внедрить такой слой.
Это не бесплатно. Семь файлов, несколько уровней косвенного обращения и токен для вставки одной строки — вот реальные затраты. Для прототипа с двумя экранами это чрезмерная сложность, поэтому правильным решением будет намеренное создание одного большого сервиса. Точка безубыточности обычно наступает тогда, когда двум функциям требуется один и тот же состояние сессии, или когда приходится впервые объяснять логику обновления коллеге.
Заключение
Вся концепция сводится к одному правилу: зависимости передаются только в одном направлении, и каждая функция находится ровно в одном файле.
interceptor → service → { api, store } → models
На практике это означает: шлюз не имеет доступа к состоянию, хранилище не осуществляет сетевых операций, фасад оставляет задачу маршрутизации за своими вызывающими компонентами, а компоненты общаются только с фасадом. Ни один слой не взаимодействует с элементами, расположенными выше по цепочке.
Именно это ограничение превратило синхронный вызов throw при скрытом частичном выходе из системы в короткий неудачный тест. Благодаря ему вопрос «Какие запросы содержат токен авторизации?» получил ответ в одной строке. Кроме того, переход от использования localStorage к куки с атрибутом httpOnly становится просто заменой одного адаптера вместо необходимости перебирать всю кодовую базу.
Названия этих паттернов звучат внушительно: Gateway, Facade, Ports and Adapters, Chain of Responsibility, Policy Enforcement Point. На деле все они выражают одну и ту же идею — назначить каждому решению единое место и сделать направление зависимостей очевидным. Все остальное следует из этого.