Головна / Статті

Розбір Angular AuthService: Гейтвей, Сторіжка, Фасад та Порти

Розділіть перевантажений Angular AuthService на гейтвей, магазин сигналів, порт зберігання та фасад, з оновленням токену одноразового використання, інтерцептором з обмеженим діапазоном та захисниками, які можна протестувати.

Видавець: REACTAPP.TOP17 хв читання3866 слів

Джерела: 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. Його часто називають репозиторієм, але репозиторій означає абстракцію, схожу на колекцію, яку можна запитувати. Типований обгорток навколо віддалених викликів більш точно можна описати як гейтвей.

Перевагою є інкапсуляція знань про протокол. Якщо кінцева точка входу змінюється з /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),
});

Це і є суть концепції Ports and Adapters (шестикутна архітектура), скорочена до її основ. Інтерфейс AuthStorage визначає порт. Справжній браузерний механізм зберігання підключається до нього як один з адаптерів, а кількохрядковий клас MemoryStorage у пам’яті — як інший.

Порт вирішує три конкретні проблеми:

  1. У тестах використовується справжній магазин даних. Вставка MemoryStorage дозволяє перевірити справжню поведінку зберігання даних — запис, перезавантаження та відновлення — без необхідності очищення глобального стану між тестовими випадками.
  • Server-side rendering більше не спричиняє збоїв. У сервері відсутній 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();
      }
    }
    

    Це фасад моделі „Gang of Four“, який також виступає посередником: це єдиний шар, який знає про існування як гейтвею, так і сховища.

    Поновне експортування сигналів сховища з фасаду — це короткий код із великим ефектом: компоненти залежать лише від AuthService та не знають про наявність окремого AuthStore. Це робить сховище деталлю реалізації функціоналу, тож його можна буде замінити на NgRx пізніше, і жоден компонент не потребуватиме змін.

    Зверніть увагу, що Router все ще відсутній. Фасад не здійснює навігацію, оскільки це питання презентації. Сторінка входу має переходити за адресою returnUrl, кнопка вихіду з панелі керування — за адресою /login, а діалог про закінчення терміну сесії може взагалі не здійснювати навігації. Виклик router.navigate() всередині logout() змусив би застосовувати одні й ті самі правила до кожного викликача. Натомість компонент, який викликає цю функцію, сам приймає рішення:

    protected logout(): void {
      this.auth.logout();
      void this.router.navigateByUrl('/login');
    }
    

    Ціна цього — два рядки коду в компоненті. Натомість AuthService можна викликати з інтерцепторів, захисників та тестів без необхідності виконання перенаправлення, якого ніхто не просив.

    Найсильнішим аргументом на користь використання фасаду є проблема, яку важко вирішити будь-де інде. Панель керування надсилає вісім запитів паралельно. Токен доступу закінчив свою дію півхвилини тому, тож усі вісім запитів майже одночасно отримують код 401. Наївний інтерцептор реагує на це, починаючи вісім запитів на оновлення. Багато бекендів змінюють токен оновлення щоразу після його використання, тож до моменту надходження наступних запитів токен, який вони містять, вже є недійсним, і користувач виходить з сторінки, яку відкрив кілька секунд тому.

    Рішенням є механізм „одного польоту“, який також називається об’єднанням запитів або запобіганням ситуації „грозового стада“: перший запитувач починає процес оновлення, а всі інші приєднуються до вже триваючого запиту. Та сама ідея у налаштуваннях 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, він виконується один раз під час кожної спільної обробки, а не один раз на кожного підписника. Якби він був розташований після, він би перезаписував базу даних для кожного з восьми користувачів.
    • 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. Він вирішує дві серйозні проблеми:

    1. Витік токенів до інших джерел. Інтерцептор без перевірки URL додає токен доступу користувача до кожного зовнішнього запиту, включаючи завантаження даних у об’єктне сховище, постачальників карт та кінцевих точок аналітики. Це призводить до передачі облікових даних стороннім особам лише через надмірну активність інтерцептора.
    2. Запити на оновлення з простроченим токеном. Без списку виключень сам запит на оновлення надсилається зі старим токеном доступу. Якщо бекенд є більш лояльним, він його ігнорує; якщо ж суворішим — повертає помилку 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. Тут немає дій, редукторів, ефектів чи циклу відправки команд. 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. Насправді всі вони виражають одну й ту саму ідею – надати кожному рішенню окреме місце та зробити напрямок залежностей очевидним. Усе інше випливає з цього.