Entwirren eines Angular AuthService: Gateway, Store, Facade und Ports
Teilen Sie den überdimensionierten Angular AuthService in ein Gateway, einen Signal-Store, einen Storage-Port und eine Fassade auf, inklusive Token-Aufruf bei erster Nutzung, einem scoped Interceptor sowie testbaren Guards.
Quellen: Clean Architecture in Angular: Refactoring the AuthService with Ports & Adapters
Fast jede Angular-Anwendung verfügt über einen AuthService, und in vielen Projekten ist es die Datei, an der niemand etwas ändern möchte. Sie kombiniert HTTP-Aufrufe, Token-Speicherung, reaktiven Zustand, Aktualisierungslogik und Navigation – daher bedeutet die Änderung eines Elements das Durchlesen aller Teile. Dieser Artikel zeigt, wie man diesen Service in kleinere Schichten mit einseitigen Abhängigkeiten umstrukturieren kann, und weist auf zwei tatsächliche Fehler hin, die durch die neuen Strukturen zum Vorschein kamen: ein Aktualisierungsfehler, der das Ausloggen übersprang, sowie ein Interceptor, der Träger-Token an Drittanbieter-APIs durchsickern ließ. Am Ende erhalten Sie ein Konzept, das Sie auf Ihre eigenen Auth-Funktionen anwenden können, sowie eine klare Einschätzung, wann es sich nicht lohnt, zusätzliche Dateien zu erstellen.
Wie das „Gott-Objekt“ AuthService entsteht
Niemand entwirft absichtlich einen Authentifizierungs-Dienst mit 300 Zeilen Code. Es beginnt mit einer login()-Methode, die einen Endpunkt aufruft. Danach muss der Token irgendwo gespeichert werden, weshalb ein Feld hinzukommt. Der Token muss auch nach einem Neuladen erhalten bleiben, daher wird localStorage verwendet. Anschließend kommen Refresh-Tokens hinzu, dann ein BehaviorSubject, damit Komponenten auf den Login-Zustand reagieren können, sowie ein router.navigate('/login') beim Ausloggen – schließlich schien es keinen besseren Platz dafür zu geben. Ein Jahr und ein halbes später importiert die Klasse Router, HttpClient und localStorage, und um die Strategie zur Speicherung des Tokens zu ändern, ist es notwendig, den gesamten Codeverlauf zu verstehen.
login() teilen: Ein Feld aktualisieren, zwei Speicherschlüssel schreiben, den Zustand ausgeben und navigieren.
@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
}
Technisch gesehen ist nichts davon fehlerhaft – und genau das ist die Falle. Es funktioniert, bis sich eines Tages eine Anforderung ändert, und dann erweist sich jede Aufgabe als mit jeder anderen verknüpft. Der Rest dieses Artikels beschreibt die Umstrukturierung der Authentifizierungsfunktion in einem kleinen Angular 22-Projekt. Die resultierende Struktur ist nichts Neues, und jeder Bestandteil hat einen bekannten Namen – doch ihre Anwendung auf konkreten Code mit konkreten Fehlern macht die Sache besser verständlich als ein Diagramm.
Die Zielstruktur
Die einzige Klasse wird zu einer Handvoll fokussierter Dateien, die in einem cross-cuttingen core-Ordner sowie in einem Ordner für die Authentifizierungsfunktion gruppiert sind.
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/
Sieben kleine Dateien anstelle einer großen ist nur dann ein guter Kompromiss, wenn jede Datei für sich allein leichter verständlich ist als das ursprüngliche Ganze. Ein praktischer Test besteht darin, ob man die Aufgabe jeder Datei in einem Satz beschreiben kann. Hier ist das möglich, und das Abhängigkeitsdiagramm ist kurz genug, um es in wenigen Zeilen darzustellen:
auth-interceptor → auth-service → auth-api (talks to the network)
(cross-cutting) (facade) → auth-store (holds the truth)
→ auth-models (describes the shapes)
Jeder Pfeil zeigt nach rechts. Diese eine Regel – dass Abhängigkeiten in einer einzigen Richtung ohne Zyklen fließen – sorgt dafür, dass das Design zusammenhält. Jeder Abschnitt darunter ist eine Folge davon.
Die Schnittstelle: Netzwerkwissen an einem Ort
Schicht eins umschließt die HTTP-Endpunkte und tut nichts anderes. Jede Methode mappt ihre Argumente auf eine typisierte Anfrage und gibt das resultierende Objekt zurück.
@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`);
}
}
Dies ist das Gateway-Muster aus Martin Fowlers Patterns of Enterprise Application Architecture. Es wird oft als Repository bezeichnet, doch ein Repository impliziert eine abstrakte Struktur ähnlich einer Sammlung, die abgefragt werden kann. Ein typisiertes Wrapper um Fernaufrufe lässt sich ehrlicherweise als Gateway beschreiben.
Der Vorteil besteht in der Kapselung des Protokollwissens. Wenn sich das Anmeldepunkt von /auth/login auf /v2/sessions verschiebt, beschränkt sich die Änderung auf diese Klasse; der Rest der Anwendung behandelt niemals eine URL.
Was fehlt, ist genauso wichtig wie das, was vorhanden ist: kein Zustand, kein localStorage, kein Router und keine Fehlerbehandlung jenseits dessen, was HTTP selbst bietet. Jede Methode wandelt ihre Argumente einfach in ein „kaltes“ Observable um, wodurch es trivial ist, die Klasse in Tests durch eine Fälschung zu ersetzen.
Die Basis-URL wird statt per Hard-Coding über ein Einsetzungs-Token injiziert:
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL');
Ein Token für einen einzigen String mag wie eine Zeremonie erscheinen. Das ist er jedoch nicht, und der Abschnitt zur Interception erklärt, warum es zu einer der wertvollsten Komponenten dieser Funktion wird.
Eine Anmerkung zum Decorator: Die Beispiele verwenden @Service() wie im Projekt definiert. Falls Ihre Angular-Version dieses nicht bietet, erfüllt @Injectable({ providedIn: 'root' }) dieselbe Funktion; prüfen Sie die Dokumentation für Ihre Version.
Der Store: Ein einziger Eigentümer für den Session-Zustand
Der Store speichert die Session und ist das einzige Element, das sie ändern darf. Er bewahrt den Zugriffstoken, den Erneuerungstoken sowie den Benutzer in Signalen auf.
@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);
}
// ...
}
Die wichtigste Information geht leicht übersehen: Jedes schreibbare Signal ist privat, und jedes öffentliche Signal wird über asReadonly() freigegeben.
Dies ist die Trennung von Befehl und Abfrage, die durch das Typensystem durchgesetzt wird. Jeder kann lesen; Schreiben erfolgt nur über Methoden, die nach einer Absicht benannt sind, wie zum Beispiel setTokens und clear. Ein Komponente kann keinen Token direkt setzen. Das ist keine Konvention, der sie folgen soll – es ist einfach unmöglich, denn der read-only Typ Signal<T> verfügt über keine set()-Methode.
Der Vorteil zeigt sich beim Debuggen. Die Frage „Wer hat diesen Zustand geändert?“ ist dann nicht mehr eine Untersuchungsaufgabe, denn nur wenige benannte Methoden in einer Datei können den Zustand ändern. Vergleichen Sie das mit einem öffentlichen BehaviorSubject, bei dem Dutzende von Komponenten den Methodenaufruf next() ausführen könnten.
Der Store ist außerdem die einzige Schicht, die mit der Persistenz zu tun hat, und er behandelt alles, was er zurückliest, als unzuverlässige Eingabe.
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 ist im Grunde ein Textfeld, das der Benutzer bearbeiten kann, und Browsererweiterungen oder ältere Versionen Ihrer eigenen Anwendung können unerwartete Daten darin hinterlassen. Das Aufrufen von JSON.parse darauf ohne try/catch birgt das Risiko eines Absturzes beim Starten der Anwendung, und der strenge Modus von TypeScript warnt Sie nicht, da JSON.parse einen Wert vom Typ any zurückgibt. Durch Überprüfung der Struktur und Löschung des Eintrags, falls diese nicht übereinstimmt, wird verhindert, dass ein beschädigter Session-Status den Benutzer ausloggt, anstatt ihn vor einem leeren Bildschirm zu lassen.
Der Speicher-Port: Persistenz austauschbar machen
Die Abhängigkeit AUTH_STORAGE im Store ist nicht localStorage selbst. Es handelt sich um eine Schnittstelle mit einem Einbettungstoken, dessen Standardfaktor eine Implementierung auswählt.
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),
});
Das ist die Idee von Ports und Adaptern (sechseckige Architektur) in ihren Grundzügen. Die Schnittstelle AuthStorage definiert den Port. Reales Browser-Speicher-Verhalten wird als ein Adapter daran angeschlossen, und eine wenige Zeilen lange In-Memory-Klasse MemoryStorage als ein weiterer Adapter.
Der Port löst drei konkrete Probleme:
- Tests verwenden einen echten Store. Durch das Einbinden von
MemoryStoragekann man tatsächliches Persistenzverhalten wie Schreiben, Neu laden und Wiederherstellen testen, ohne dass globale Zustände zwischen Testfällen bereinigt werden müssen.
localStorage existiert auf dem Server nicht, weshalb die Implementierung automatisch auf den Speicher im Arbeitsspeicher ausweicht.localStorage bedeutet, dass jedes auf der Seite ausgeführte Skript darauf zugreifen kann – was eine echte XSS-Exposition darstellt. Die zuverlässige Lösung ist ein httpOnly-Cookie, das vom Backend gesetzt wird. Durch den Speicher hinter einem Port betrifft diese Migration nur einen Adapter, anstatt eine umfassende Suche und Ersetzung im gesamten Codebasis zu erfordern; bis dahin bleibt das Risiko in einer Datei mit einem erklärenden Kommentar bestehen.Punkt drei ist allgemeiner gehalten: Eine gute Architektur verwandelt keine schlechte Entscheidung in eine gute. Sie macht die Entscheidung lokal, sichtbar und einfach rückgängig zu machen.
Die Fassade: Orchestrierung ohne Eigentumsverhältnis
AuthService bleibt bestehen, hat aber eine viel kleinere Aufgabe. Es koordiniert das Gateway und den Store, besitzt aber selbst nichts.
@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();
}
}
Das ist die Gang of Four-Fassade, die außerdem als Vermittler fungiert: Sie ist die einzige Schicht, die weiß, dass sowohl das Gateway als auch der Store existieren.
Die Wiederausgabe der Signale des Stores aus der Fassade ist eine einfache Maßnahme mit großer Wirkung: Die Komponenten verlassen sich nur auf AuthService und haben keine Ahnung von der Existenz eines separaten AuthStore. Dadurch wird der Store zu einem Implementierungsdetail der Funktion, sodass man ihn später durch NgRx ersetzen kann, ohne dass eine Komponente geändert werden muss.
Bemerken Sie, dass Router immer noch fehlt. Die Fassade kümmert sich nicht um die Navigation, da diese zu den Aufgaben der Präsentation gehört. Die Anmeldeseite möchte zu einer returnUrl weitergeleitet werden, der Ausloggen-Button des Dashboards möchte /login, und ein Dialog wegen abgelaufener Sitzung könnte überhaupt nicht navigieren. Das Aufrufen von router.navigate() innerhalb von logout() würde einer Policy für jeden Aufrufer aufzwingen. Stattdessen entscheidet das aufrufende Komponente selbst:
protected logout(): void {
this.auth.logout();
void this.router.navigateByUrl('/login');
}
Der Preis dafür sind zwei Zeilen im Komponentencode. Im Gegenzug kann AuthService von Interzeptoren, Guards und Tests aufgerufen werden, ohne dass eine nicht gewünschte Umleitung ausgelöst wird.
Konkurrierende Aktualisierungen in eine einzige Anfrage zusammenfassen
Der stärkste Grund für eine Fassade ist ein Problem, das an anderer Stelle nur schwer zu lösen ist. Ein Dashboard sendet acht Anfragen parallel. Das Zugriffstoken ist vor einer halben Minute abgelaufen, weshalb alle acht Anfragen fast gleichzeitig mit einem 401-Fehler zurückkommen. Ein naiver Interceptor reagiert darauf, acht Erneuerungsanfragen zu starten. Viele Backends wechseln das Erneuerungstoken jedes Mal, wenn es verwendet wird; daher ist das Token, das bei den späteren Anfragen mitgeliefert wird, bereits ungültig, und der Benutzer wird von einer Seite ausgeloggt, die er erst vor wenigen Sekunden geöffnet hat.
Die Lösung ist das sogenannte Single-Flight-Prinzip, auch als Anfragekonsolidierung oder Verhinderung von „Thundering Herd“ bezeichnet: Der erste Anrufer startet die Erneuerung, und alle anderen Anrufer schließen sich der bereits laufenden Anfrage an. Dasselbe Konzept in einer Axios-Einstellung wird in „Warum führen gleichzeitige 401-Fehler zum Ausloggen der Benutzer?“ erläutert.
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$;
}
Die drei Operator müssen in dieser Reihenfolge auftreten, aus drei unterschiedlichen Gründen:
tapspeichert die neuen Tokens. Da es vorshareReplaykommt, wird es einmal pro gemeinsamer Ausführung und nicht einmal pro Abonnent ausgeführt. Würde es weiter hinten platziert werden, würde es den Speicher für jeden der acht Aufrufer überschreiben.finalizesetzt das im Cache gespeicherte Feld zurück, sobald die gemeinsame Anfrage abgeschlossen ist oder Fehler auftritt. Ohne dieses Verhalten würde ein späterer Ablauf der Tokens dazu führen, dass die bereits verwendete alte Antwort erneut abgespielt wird, anstatt einen neuen Aufruf auszulösen.shareReplay({ bufferSize: 1, refCount: true })sendet einen einzigen HTTP-Aufruf an alle Abonnenten und beendet ihn, sobald der letzte Abonnent sich abmeldet.
Die Zuweisung ??= sorgt dafür, dass nur ein einziger Anruf stattfindet: Nur der Aufrufer, der das Feld als leer erkennt, erstellt eine Anfrage, und alle, die zu dem Zeitpunkt noch warten, erhalten dasselbe Ergebnis. Dadurch entsteht nur ein einziger Netzwerkaufruf sowie eine einmalige Token-Austauschung.
Eine synchrone Ausnahme, die das Abmelden überspricht
Die Verwendung von throwError in der Refresh-Methode ist keine stilistische Entscheidung. Die frühere Version der Schutzklausel sah so aus:
if (!refreshToken) throw new Error('No refresh token available');
Der Code liest sich zwar natürlich, ist aber fehlerhaft. Die Trennlinie zwischen den Schichten machte den Fehler sichtbar. Der Interceptor verarbeitet refresh() auf folgende Weise:
return auth.refresh().pipe(
switchMap(/* retry the original request */),
catchError((refreshError) => {
auth.logout();
return throwError(() => refreshError);
}),
);
Ein synchroner throw innerhalb von refresh() tritt auf, während die Expression auth.refresh() noch ausgewertet wird, also bevor .pipe() überhaupt aufgerufen wird. Der Operator catchError erhält nie die Gelegenheit, den Fehler zu erkennen, weshalb auth.logout() niemals ausgeführt wird. Die Situation, für die die Schutzklausel ursprünglich geschrieben wurde – ein fehlendes Refresh-Token – war genau die, in der der Benutzer stillschweigend nur teilweise authentifiziert blieb.
Durch die Rückgabe von throwError(() => ...) wird das Versagen in eine Fehlerbenachrichtigung im Stream umgewandelt, sodass der Logout-Teil des Interceptors wie vorgesehen ausgelöst wird. Als allgemeine Regel sollte eine Methode, deren Signatur einen Observable zurückgibt, Fehler über diesen Observable melden und nicht durch das Werfen von Ausnahmen.
Die architektonische Lektion ist, dass sich dieser Fehler leicht in einer 300 Zeilen langen Klasse verstecken kann, in der HTTP-, Zustands- und Fehlerbehandlung miteinander vermischt sind, weil nichts jemanden zwingt, festzulegen, was eine bestimmte Methode ihren Aufrufern verspricht. Wenn refresh() zu einer öffentlichen Fassadenmethode mit einer klaren Aufgabe wird, wird die Frage „Gibt diese Methode einen fehlerhaften Stream zurück oder wirft sie einen Fehler aus?“ offensichtlich – genauso wie der entsprechende 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();
});
Schichten verhindern keine Fehler. Sie geben Fehlern einen Ort, an dem sie sichtbar werden, und das ist der größte Wert von ihnen.
Der Interceptor: Querschnittsregeln und ein begrenztes Token
Der Interceptor wendet die Authentifizierungsregeln auf jede ausgehende Anfrage an: Er fügt das Token hinzu und bei einem 401-Fehler aktualisiert er es und versucht es einmal erneut.
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);
}),
);
}),
);
};
Angulars Interceptor-Kette ist eine direkte Implementierung des Prinzips der Verantwortungskette – jede Interceptor-Funktion modifiziert die Anfrage, während sie weitergeleitet wird. Noch wichtiger ist, dass die von ihr durchgesetzte Strategie (Anhängen von Anmeldeinformationen sowie Behandlung eines 401-Fehlers als Signal zum Erneuern und erneuten Versuch) auf die gesamte API-Oberfläche angewandt wird, was sie zu einem typischen Querschnittsthema macht. Sie gehört nicht zu irgendeinem einzelnen Aufrufer. Ohne Interceptor müssten alle Service-Methoden, die auf authentifizierte Endpunkte zugreifen, den Token immer wieder übergeben und standardisierte Wiederholungslogik ausführen. Sollte die Wiederholungslogik in einer von dreißig Kopien fehlerhaft sein, entsteht ein Fehler, der in der Produktion wöchentlich auftritt, aber niemals in der Entwicklung. Für eine umfassendere Handhabung fehlgeschlagener Anfragen in Angular siehe Design für fehlgeschlagene Anfragen in Angular: States, Interceptors und Wiederholungen.
Betrachten Sie nun genau die Schutzklausel am Anfang der Funktion:
if (!isOurApi(req, baseUrl) || isUnauthenticatedRequest(req)) {
return next(req);
}
Genau hier kommt die eingefügte API_BASE_URL zum Tragen. Sie löst zwei ernsthafte Probleme:
- Durchsickern von Tokens an andere Quellen. Ein Interceptor ohne URL-Prüfung fügt das Zugriffstoken des Benutzers jeder Ausgangsanfrage hinzu – einschließlich Uploads in Objektspeicher, Karten-Tile-Anbieter und Analyseendpunkte. Dadurch gelangen Zugangsdaten an unzuständige Dritte, nur weil der Interceptor zu voreilig handelte.
- Aufrufe zum Erneuern mit abgelaufenem Token. Ohne die Ausschlussliste wird der Erneuerungsanfrage selbst das veraltete Zugriffstoken mitgesendet. Ein nachsichtiger Backend ignoriert sie; ein strenger Backend antwortet mit einem weiteren 401-Fehler, wodurch sich ein Erneuerungslauf ergeben kann.
Sowohl Lösungen als auch Korrekturen erfordern nur eine einzige Zeile, sobald die Basis-URL eingefügt werden kann. Ohne sie hat der Interceptor keine zuverlässige Möglichkeit, herauszufinden, welche Anfragen von Ihnen stammen.
Noch ein Detail ist erwähnenswert: Die erneute Anfrage wird mit next(...) gesendet, nicht indem die Interceptor-Kette von oben neu durchlaufen wird. Wenn auch die erneut gesendete Anfrage 401 zurückgibt, breitet sich der Fehler nur einmal auf den Aufrufer aus und stoppt anschließend – dadurch kann der Interceptor nicht in einer Endlosschleife verharren.
Schutzmechanismen: Routenzugriff als Richtlinie
Die letzte Schicht beantwortet für den Router eine einzige Frage: Darf diese Navigation fortgesetzt werden?
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;
};
Aus Sicht der Sicherheit ist ein Guard ein Punkt zur Durchsetzung von Richtlinien: Die Zugriffsentscheidung wird an genau einem Ort getroffen. Ohne ihn neigen Authentifizierungsprüfungen dazu, in Konstruktormethoden von Komponenten zu gelangen, werden unter Zeitdruck bei der Erstellung von Routen vergessen und führen zu Bildschirmen, die vor der Umleitung kurz eine als privat erscheinende Benutzeroberfläche anzeigen.
Es wird absichtlich ein UrlTree zurückgegeben anstelle einer imperativen Navigation. Der Router gibt den versuchten Navigationsvorgang auf und führt die Umleitung in einem einzigen Schritt aus, sodass die geschützte Route niemals auf dem Bildschirm angezeigt wird. Der zweite Guard, guestGuard, ist das Spiegelbild davon: Er hält authentifizierte Benutzer von der Anmeldeseite fern.
Anschließend werden die Guards den Routen zugeordnet:
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' },
];
Der von authGuard gesetzte Abfrageparameter returnUrl erreicht das Anmeldungsmodul ohne jeglichen manuellen ActivatedRoute-Code. Mit provideRouter(routes, withComponentInputBinding()) werden die Abfrageparameter an die entsprechenden Signal-Eingänge gebunden:
readonly returnUrl = input('/dashboard');
// ...
this.auth.login(username, password).subscribe({
next: () => this.router.navigateByUrl(this.returnUrl()),
error: () => { /* ... */ },
});
Ein aufschlussreicher Hinweis aus dem Projekt: Schon vor der Existenz des Guards stellte der Store bereits isAuthenticated zur Verfügung – ein berechnetes Signal, das nichts ausliest. Solch ungenutzter Zustand ist ein Zeichen für ein unvollendetes Design. Er ist nicht harmlos; es handelt sich dabei um ein Versprechen, das die Anwendung noch nicht eingehalten hat.
Was die Schichten in Tests für Sie bringen
Der klarste Vorteil ist, wie wenig Vorarbeit die Tests benötigen. Es gibt keine Komponenten-Fixtures, keine Aufräumarbeiten im localStorage und keine manuell geschriebenen HttpClient-Mock-Objekte. Die untenstehende Konfiguration stellt Angulos HTTP-Test-Backend bereit und ersetzt genau zwei Token – die Basis-URL sowie den Speicher-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');
});
Der Single-Flight-Test ruft refresh() zweimal auf und verwendet anschließend http.expectOne, der fehlschlägt, wenn mehr als eine passende Anfrage gesendet wird. Dadurch wird die Konsolidierungs-Garantie in wenigen Zeilen überprüft, und diese Überprüfung bleibt auch dann bestehen, wenn jemand refresh() ein Jahr später umstrukturiert. Zwei injizierte Token bilden das gesamte Test-Setup – genau dafür waren die Ports gedacht.
Was dieses Design nicht ist
Die genaue Definition der Grenzen eines Musters ist genauso nützlich wie seine Benennung.
Es handelt sich weder um Redux noch um NgRx. Es gibt keine Actions, Reducers, Effects oder einen Dispatch-Zyklus. AuthStore ist ein eingebetteter Zustand mit methodenbezeichneten Intenten. Wenn Sie Zeitreise-Debugging, Koordination von Effects über verschiedene Funktionen oder ein serialisiertes Action-Log für Wiedergabe benötigen, wechseln Sie zu NgRx; die Fassade sorgt dafür, dass die Komponenten den Wechsel nicht bemerken.
Es handelt sich nicht um eine vollständige Clean Architecture. Hier finden Sie keine Use-Case-Klassen, und das Domain-Modell wird nicht vom Datenformat getrennt gehalten. Der Typ User in auth-models.ts entspricht einfach der Struktur der API-Antwort. Ein strenger Ansatz der Clean Architecture würde separate Typen definieren und zwischen ihnen übersetzen. Bei nur wenigen Feldern ist diese Abbildungsschicht aufwendiger, als sie nützt, doch wenn die Struktur der API und Ihr Domain-Modell auseinanderdriften, ist das der Punkt, an dem man eine solche Struktur einführen sollte.
Es ist nicht kostenlos. Sieben Dateien, mehrere Indirektionswege sowie ein Eintragstoken für einen einzigen String sind echte Kostenfaktoren. Bei einem Prototyp mit zwei Bildschirmen handelt es sich dabei um übermäßige Komplexität – daher ist es richtig, absichtlich einen einzigen großen Dienst zu entwickeln. Der Break-Even-Punkt tritt in der Regel ein, wenn zwei Funktionen denselben Sitzungszustand benötigen oder man zum ersten Mal der Kollegin die Aktualisierungslogik erklären muss.
Zusammenfassung
Das gesamte Design lässt sich auf eine Regel reduzieren: Abhängigkeiten fließen in einer Richtung, und jede Aufgabe befindet sich genau in einer Datei.
interceptor → service → { api, store } → models
In der Praxis bedeutet das: Das Gateway hat keinen Zugriff auf den Zustand, der Store führt keine Netzwerkkommunikation durch, die Fassade überlässt das Routing ihren Aufrufern, und Komponenten kommunizieren nur mit der Fassade. Keine Schicht greift zurück in die übergeordneten Ebenen.
Diese Einschränkung ist es, die einen synchronen throw bei einem versteckten Teil-Abmelden in einen kurzen, fehlgeschlagenen Test verwandelte. Dadurch wurde die Frage „Welche Anfragen enthalten das Token?“ zu einer Frage mit einer einzeiligen Antwort. Zudem macht sie einen zukünftigen Wechsel von localStorage zu httpOnly-Cookies zu einem einfachen Austausch eines Adapters anstelle einer Durchsuchung des gesamten Codebases.
Die Namen dieser Muster klingen beeindruckend: Gateway, Facade, Ports and Adapters, Chain of Responsibility, Policy Enforcement Point. Im Grunde drücken sie alle dieselbe Idee aus: Jede Entscheidung erhält einen festen Ort, und die Abhängigkeitsrichtungen werden klar definiert. Alles Weitere folgt daraus.