Home / Articles

Untangling an Angular AuthService: Gateway, Store, Facade and Ports

Split a bloated Angular AuthService into a gateway, a signal store, a storage port and a facade, with single-flight token refresh, a scoped interceptor and testable guards.

Publisher: REACTAPP.TOP17 min read3866 words

This article is published in English.

Sources: Clean Architecture in Angular: Refactoring the AuthService with Ports & Adapters

Nearly every Angular application has an AuthService, and in many projects it is the file nobody wants to touch. It mixes HTTP calls, token persistence, reactive state, refresh logic and navigation, so changing any one of them means reading all of them. This article walks through restructuring that service into small layers with one-way dependencies, and shows two real bugs the new boundaries exposed along the way: a refresh failure that skipped logout, and an interceptor that leaked bearer tokens to third-party APIs. You will come away with a layout you can apply to your own auth feature, and a clear sense of when it is not worth the extra files.

How the god-object AuthService happens

Nobody designs a 300-line auth service on purpose. It begins as a login() method that calls one endpoint. Then the token has to be kept somewhere, so a field appears. The token must survive a reload, so localStorage is added. Refresh tokens arrive next, then a BehaviorSubject so components can react to login state, then a router.navigate('/login') on logout because there seemed to be no better place for it. A year and a half later the class imports Router, HttpClient and localStorage, and swapping the token storage strategy requires understanding the entire file.

A condensed version of that starting point looks like this. Pay attention to how many unrelated concerns share the single tap in login(): updating a field, writing two storage keys, emitting state and navigating.

@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
}

None of this is technically broken, and that is the trap. It works until the day one requirement changes, and then every concern turns out to be tangled with every other one. The rest of this article follows the restructuring of the auth feature in a small Angular 22 project. The resulting shape is not new, and every piece has a well-known name, but applying it to concrete code with concrete bugs makes the case better than a diagram does.

The target layout

The single class becomes a handful of focused files, grouped into a cross-cutting core folder and a feature folder for auth.

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/

Seven small files instead of one big one is only a good trade if each file is easier to understand on its own than the original was as a whole. A practical test is whether you can state every file's job in one sentence. Here you can, and the dependency picture is short enough to draw in a few lines:

auth-interceptor  →  auth-service  →  auth-api    (talks to the network)
   (cross-cutting)      (facade)    →  auth-store  (holds the truth)
                                    →  auth-models (describes the shapes)

Every arrow points to the right. That one rule, dependencies flow in a single direction with no cycles, is what makes the design hold together. Each section below is a consequence of it.

The gateway: network knowledge in one place

The lowest layer wraps the HTTP endpoints and does nothing else. Each method maps its arguments to a typed request and returns the resulting observable.

@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`);
  }
}

This is the Gateway pattern from Martin Fowler's Patterns of Enterprise Application Architecture. It is often called a repository, but a repository implies a collection-like abstraction you can query. A typed wrapper around remote calls is more honestly described as a gateway.

The benefit is containment of protocol knowledge. If the login endpoint moves from /auth/login to /v2/sessions, the edit is confined to this class; the rest of the app never handles a URL.

What is missing matters as much as what is there: no state, no localStorage, no Router, and no error handling beyond what HTTP itself provides. Each method simply turns its arguments into a cold Observable, which makes the class trivial to replace with a fake in tests.

The base URL is injected rather than hard-coded, through an injection token:

export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL');

A token for a single string can look like ceremony. It is not, and the interceptor section explains why it ends up being one of the most valuable lines in the feature.

A side note on the decorator: the snippets use @Service() as written in the project. If your Angular version does not offer it, @Injectable({ providedIn: 'root' }) fills the same role; check the documentation for the version you are on.

The store: one owner for session state

The store holds the session and is the only thing allowed to change it. It keeps the access token, refresh token and user in signals.

@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);
  }
  // ...
}

The detail that matters most is easy to miss: each writable signal is private, and every public signal is exposed through asReadonly().

That is command-query separation enforced by the type system. Anyone may read; writing happens only through methods named after an intent, such as setTokens and clear. A component cannot set a token directly. This is not a convention it is asked to follow; it is impossible, because the read-only Signal<T> type has no set() method.

The payoff shows up during debugging. "Who changed this state?" stops being an investigation, because only a few named methods in one file can mutate the session. Compare that with a public BehaviorSubject, where any of dozens of components might have called next().

The store is also the only layer that touches persistence, and it treats whatever it reads back as untrusted input.

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 is effectively a text field the user can edit, and browser extensions or older builds of your own app can leave unexpected data in it. Calling JSON.parse on it without a try/catch risks a crash during application startup, and TypeScript's strict mode will not warn you, because JSON.parse returns any. Checking the shape and deleting the entry when it does not match means a corrupted session logs the user out instead of leaving them staring at a blank screen.

The storage port: making persistence swappable

The AUTH_STORAGE dependency in the store is not localStorage itself. It is an interface, with an injection token whose default factory chooses an implementation.

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),
});

That is the Ports and Adapters idea (hexagonal architecture) reduced to its essentials. The interface, AuthStorage, defines the port. Real browser storage plugs into it as one adapter, and a few-line in-memory MemoryStorage class plugs in as another.

The port solves three concrete problems:

  1. Tests use a real store. Injecting MemoryStorage lets you exercise actual persistence behavior, writing, reloading and restoring, without global state that must be cleaned up between test cases.
  2. Server-side rendering stops crashing. localStorage does not exist on the server, and the factory falls back to memory storage automatically.
  3. The security decision becomes reviewable. Keeping a refresh token in localStorage means any script running on the page can read it, which is a genuine XSS exposure. The robust fix is an httpOnly cookie set by the backend. With storage behind a port, that migration touches one adapter instead of requiring a search-and-replace across the codebase, and until it happens, the risk sits in one file with a comment explaining it.

The third point generalizes. Good architecture does not turn a bad decision into a good one. It makes the decision local, visible and cheap to undo.

The facade: orchestration without ownership

AuthService survives, but with a much smaller job. It coordinates the gateway and the store and owns nothing itself.

@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();
  }
}

This is the Gang of Four Facade, and it also acts as a mediator: it is the only layer that knows both the gateway and the store exist.

Re-exporting the store's signals from the facade is a small line with a big effect: components depend only on AuthService and have no idea a separate AuthStore is involved. That makes the store an implementation detail of the feature, so you could replace it with NgRx later and no component would need to change.

Notice that Router is still absent. The facade does not navigate, because navigation is a presentation concern. The login page wants to go to a returnUrl, the dashboard's logout button wants /login, and a session-expired dialog might not navigate at all. Calling router.navigate() inside logout() would force one policy on every caller. Instead, the calling component decides:

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

The cost is two lines in the component. In return, AuthService can be called from interceptors, guards and tests without triggering a redirect nobody asked for.

Coalescing concurrent refreshes into one request

The strongest argument for a facade is a problem that is hard to solve anywhere else. A dashboard fires eight requests in parallel. The access token expired half a minute ago, so all eight come back with 401 almost simultaneously. A naive interceptor responds by starting eight refresh calls. Many backends rotate the refresh token each time it is used, so by the time the later calls arrive, the token they carry has already been invalidated, and the user is logged out of a page they opened seconds ago.

The fix is single-flight, also described as request coalescing or thundering-herd prevention: the first caller starts the refresh, and every other caller joins the request already in progress. The same idea in an Axios setting is covered in why concurrent 401s log users out.

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$;
}

The three operators must appear in this order, for three distinct reasons:

  • tap stores the new tokens. Because it sits upstream of shareReplay, it runs once per shared execution rather than once per subscriber. Placed downstream, it would rewrite the store for every one of the eight callers.
  • finalize resets the cached field once the shared request completes or errors. Without it, a later token expiry would get the old, already-used response replayed rather than triggering a new call.
  • shareReplay({ bufferSize: 1, refCount: true }) multicasts a single HTTP call to all subscribers and tears it down once the last subscriber leaves.

The ??= assignment is what makes it single-flight: only the caller that finds the field empty builds a request, and everyone arriving while it is pending gets that same observable. The result is one network call and one token rotation.

A synchronous throw that skipped logout

The throwError in the refresh method is not a stylistic choice. The earlier version of the guard clause looked like this:

if (!refreshToken) throw new Error('No refresh token available');

It reads naturally, and it is broken. The layer boundary is what made the bug visible. The interceptor consumes refresh() like this:

return auth.refresh().pipe(
  switchMap(/* retry the original request */),
  catchError((refreshError) => {
    auth.logout();
    return throwError(() => refreshError);
  }),
);

A synchronous throw inside refresh() fires while the expression auth.refresh() is still being evaluated, before .pipe() is ever called. The catchError operator never gets a chance to see the error, so auth.logout() never runs. The one situation the guard clause was written for, a missing refresh token, was exactly the situation in which the user was left silently half-authenticated.

Returning throwError(() => ...) turns the failure into an error notification on the stream, so the interceptor's logout branch fires as intended. As a general rule, a method whose signature returns an Observable should report failures through the observable, not by throwing.

The architectural lesson is that this bug hides easily in a 300-line class where HTTP, state and error handling are interleaved, because nothing forces anyone to define what a given method promises its callers. When refresh() becomes a public facade method with one clear job, the question "does this return a failing stream or throw?" becomes obvious, and so does the test:

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();
});

Layers do not prevent bugs. They give bugs a place where they become visible, and that is most of their value.

The interceptor: cross-cutting rules and a scoped token

The interceptor applies the auth rules to every outgoing request: attach the bearer token, and on a 401, refresh and retry once.

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's interceptor chain is a direct implementation of Chain of Responsibility, and each interceptor function decorates the request as it passes through. More importantly, the policy it enforces (attach credentials, and treat a 401 as a cue to refresh and try again) applies to the whole API surface, which makes it a textbook cross-cutting concern. It does not belong in any individual caller. Without the interceptor, every service method hitting an authenticated endpoint would repeat the token and retry boilerplate, and the day the retry logic is subtly wrong in one of thirty copies, you get a bug that shows up once a week in production and never in development. For broader handling of failed requests in Angular, see designing for failed requests in Angular.

Now look closely at the guard clause at the top of the function:

if (!isOurApi(req, baseUrl) || isUnauthenticatedRequest(req)) {
  return next(req);
}

This is where the injected API_BASE_URL pays off. It fixes two real problems:

  1. Token leakage to other origins. An interceptor with no URL check attaches the user's access token to every outbound request, including uploads to object storage, map tile providers and analytics endpoints. That hands a credential to unrelated third parties purely because the interceptor was too eager.
  2. Refresh calls carrying the expired token. Without the exclusion list, the refresh request itself went out with the stale access token attached. A lenient backend ignores it; a strict one answers with another 401, and you can end up in a refresh loop.

Both are one-line fixes once the base URL is injectable. Without it, the interceptor has no reliable way to tell which requests are yours.

One more detail is worth noticing: the retry is sent with next(...), not by re-entering the interceptor chain from the top. If the retried request also returns 401, the error propagates to the caller once and stops, so the interceptor cannot loop on itself.

Guards: route access as a policy

The last layer answers a single question for the router: may this navigation proceed?

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;
};

In security terms, a guard is a policy enforcement point: the access decision is made in exactly one spot. Without it, auth checks tend to drift into component constructors, get forgotten on the route that was added under deadline pressure, and produce screens that briefly render private-looking UI before redirecting.

Returning a UrlTree rather than navigating imperatively is deliberate. The router abandons the attempted navigation and performs the redirect in a single step, so the guarded route never flashes on screen. The second guard, guestGuard, is the mirror image: it keeps authenticated users away from the login page.

The guards are then attached to the routes:

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' },
];

The returnUrl query parameter set by authGuard reaches the login component without any manual ActivatedRoute code. With provideRouter(routes, withComponentInputBinding()), query parameters are bound to matching signal inputs:

readonly returnUrl = input('/dashboard');
// ...
this.auth.login(username, password).subscribe({
  next: () => this.router.navigateByUrl(this.returnUrl()),
  error: () => { /* ... */ },
});

One telling detail from the project: before the guard existed, the store already exposed isAuthenticated, a computed signal that nothing read. Unused state like that is a sign of an unfinished design. It is not harmless; it is a promise the application has not kept yet.

What the layers buy you in tests

The clearest payoff is how little setup the tests need. There are no component fixtures, no localStorage cleanup and no hand-written HttpClient mocks. The configuration below provides Angular's HTTP testing backend and replaces exactly two tokens, the base URL and the storage port.

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');
});

The single-flight test calls refresh() twice and then uses http.expectOne, which fails if more than one matching request was sent. That asserts the coalescing guarantee in a few lines, and it keeps asserting it after someone refactors refresh() a year from now. Two injected tokens make up the entire test harness, which is exactly what the ports were for.

What this design is not

Being precise about the boundaries of a pattern is as useful as naming it.

It is not Redux or NgRx. There are no actions, reducers, effects or dispatch cycle. AuthStore is encapsulated state with intent-named methods. If you need time-travel debugging, coordination of effects across features, or a serialized action log for replay, move to NgRx; the facade means components will not notice the switch.

It is not full Clean Architecture. You will not find use-case classes here, and the domain model is not kept apart from the wire format. The User type in auth-models.ts is simply the API response shape. A strict Clean Architecture approach would define separate types and translate between them. With only a few fields in play, that mapping layer costs more than it returns, but if the API shape and your domain model start to diverge, that is the seam to introduce next.

It is not free. Seven files, several indirections and an injection token for one string are real costs. On a two-screen prototype this is over-engineering, and writing the single large service deliberately is the right call. The break-even point usually arrives when two features need the same session state, or when you first have to explain the refresh logic to a teammate.

Wrapping up

The whole design reduces to one rule: dependencies flow in one direction, and each concern lives in exactly one file.

interceptor → service → { api, store } → models

In practice that means: the gateway has no access to state, the store does no networking, the facade leaves routing to its callers, and components talk only to the facade. No layer reaches back up the chain.

This constraint is what turned a synchronous throw from a hidden half-logout into a short failing test. It made "which requests carry the bearer token?" a question with a one-line answer. And it turns a future move from localStorage to httpOnly cookies into a change to one adapter rather than a hunt through the codebase.

The pattern names sound grand: Gateway, Facade, Ports and Adapters, Chain of Responsibility, Policy Enforcement Point. Underneath, they all express the same idea. Give every decision a single home, and make the direction of dependency obvious. The rest follows from that.