Frontend-Lernpfad: Angular an diesem Projekt lernen

Zielgruppe: Entwickler:in, die Angular an einem echten, kleinen, aber vollständigen Codebestand nachvollziehen will - nicht an einem Tutorial-Wegwerfprojekt. Jede Etappe verweist auf konkrete Dateien unter library-frontend/src/app/, die du parallel offen haben solltest.

Kommst du aus einer alten Angular-Version (v8 o. ä.) und ist vieles verblasst? Lies zuerst den Exkurs: Angular 8 → 22 - der ordnet die Änderungen von 14 Major-Releases nach Konzept und sagt dir, welche deiner alten Reflexe noch tragen.

Warum dieses Frontend zum Lernen taugt

Die SPA ist bewusst klein (rund 25 TypeScript-Dateien) und ohne "Magie": keine selbstgebaute Abstraktionsschicht, keine Framework-im-Framework-Konstruktion. Dafür steckt in fast jeder Datei eine bewusste Design-Entscheidung mit einem sichtbaren Gegenstück im selben Projekt:

Die fachliche Domäne (Bücher, Ausleihen, Mitglieder, Mahngebühren) ist Nebensache - sie ist nur der Aufhänger, an dem die Angular-Konzepte hängen.

Version: Angular 22 (package.json: @angular/core ^22.1), TypeScript ~6.0, zonen-basierte Change Detection (provideZoneChangeDetection, nicht zoneless). In zwei Etappen von Angular 18 hochgezogen (18→20, dann 20→22) - der Weg dorthin und die Regeln für Major-Updates allgemein stehen in angular-update-migration.md.

Voraussetzungen

  1. Frontend läuft:
    cd library-frontend
    npm install
    npx ng serve            # http://localhost:4200, Hot-Reload
    
  2. Backend + Keycloak laufen (sonst kommst du nicht über den Login hinaus):
    cd infra
    podman compose up -d     # mindestens: keycloak, api-gateway, catalog-service, member-service
    
    Testnutzer stehen in infra/keycloak/realm-library.json (z. B. lisa.bibliothekarin / librarian123 mit Rolle LIBRARIAN).
  3. Angular DevTools (Browser-Extension für Chrome/Firefox) - zeigt den Component-Baum, die Signal-Werte und (mit Redux DevTools zusätzlich) den NgRx-Store live.

Wenn das Backend gerade nicht läuft: Die Etappen 0-1 und 5-7 kannst du rein am Code nachvollziehen. Für 2-4 brauchst du echte API-Antworten.

Konventionen dieses Codebestands (einmal lesen, dann wiedererkennst du sie überall)

Konvention Beispiel Warum
inject() statt Konstruktor-Parameter private readonly http = inject(HttpClient); Kürzer, funktioniert auch außerhalb des Konstruktors (funktionale Guards/Interceptors), gleiche DI wie im Backend-Konstruktor-Injection-Prinzip
protected readonly für alles, was das Template nutzt protected readonly catalogService = ... strictTemplates (AOT) prüft Template-Zugriffe; protected macht klar "nur fürs eigene Template", private würde das Template nicht sehen
Ein *.service.ts pro Feature hält den Zustand CatalogService.books = signal([]) Die Komponente bleibt dünn, der Zustand überlebt einen Komponenten-Neuaufbau (Service ist providedIn: 'root')
Fehler zentral aus RFC-7807 lesen extractErrorMessage(err) in jedem Service Das Backend liefert ProblemDetail (err.error.detail) - dieselbe Fehlerkontrakt-Annahme auf beiden Seiten, siehe common-web.GlobalExceptionHandler
shared/models/*.model.ts spiegelt Backend-DTOs 1:1 BookResponsecatalog-service.api.dto.BookResponse Kein eigenes Frontend-Datenmodell, keine Übersetzungsschicht
Neue Control-Flow-Syntax @if, @else if, @for in Templates Angular 17+ - ersetzt *ngIf/*ngFor, kein CommonModule-Import mehr nötig

Etappe 0 - Wie die App startet

Dateien: src/main.ts, src/app/app.config.ts, src/app/app.component.ts + .html

Schau dir an:

Versteh warum:

Der provideAppInitializer(...)-Aufruf am Ende von app.config.ts ist der wichtigste Teil. Angular hält den Bootstrap an, bis das von authService.initialize() zurückgegebene Promise auflöst. Erst danach darf der Router die erste Route aktivieren. Ohne diese Sperre würde ein Guard über einen Login-Zustand entscheiden, der noch gar nicht aus Keycloak geladen wurde. (provideAppInitializer löste beim Update auf v19+ den älteren APP_INITIALIZER-Multi-Provider ab - siehe angular-update-migration.md.)

Mini-Übung: Öffne die Angular DevTools, geh auf den Component-Baum. Du siehst genau eine Komponente (AppComponent), solange du ausgeloggt bist. Logg dich ein - jetzt erscheint darunter die lazy geladene Feature-Komponente.


Etappe 1 - Routing und Lazy Loading

Datei: src/app/app.routes.ts

Schau dir an:

Versteh warum: Lazy Loading verkürzt die initiale Ladezeit - der Nutzer lädt nur den Code für die Seite, die er tatsächlich öffnet. Bei sechs Features ist das spürbar.

Mini-Übung: Öffne die Netzwerk-Ansicht der Browser-DevTools, lade /catalog neu, klick dann auf "Vormerkungen". Beim Klick siehst du ein neues chunk-*.js nachgeladen werden.


Etappe 2 - Ein Feature von vorne bis hinten: catalog

Dateien: src/app/features/catalog/ (catalog-search.component.ts, .html, catalog.service.ts)

Das ist das einfachste vollständige Feature - lies es als Blaupause für alle anderen (außer catalog-admin).

Schau dir an:

  1. catalog.model.ts - reine TypeScript-interfaces. BookResponse, PageResponse<T>. Der Kommentar sagt: spiegelt catalog-service.api.dto.BookResponse (Java). Keine Klasse, kein Decorator - nur die Form der JSON-Antwort.
  2. catalog.service.ts - @Injectable({ providedIn: 'root' }). Hält drei Signals: books, loading, error. Die Methode search(criteria) baut HttpParams, ruft this.http.get<PageResponse<BookResponse>>(...) auf und schreibt im subscribe-Callback die Signals.
  3. catalog-search.component.ts - injiziert den Service, baut ein searchForm (Reactive Form), ruft im Konstruktor einmal search() für die initiale Anzeige.
  4. catalog-search.component.html - [formGroup]="searchForm", (ngSubmit)="search()", und für die Ergebnisliste:
    @if (catalogService.loading()) { <mat-spinner /> }
    @else if (catalogService.books().length === 0) { <p>Keine Bücher gefunden.</p> }
    @else { <table mat-table [dataSource]="catalogService.books()"> ... </table> }
    
    Beachte: catalogService.books() - die runden Klammern lesen den Signal-Wert. Im Template löst dieser Lesezugriff automatisch die Neuberechnung aus, wenn sich das Signal ändert.

Versteh warum: Die Komponente enthält keine Logik außer "Formularwerte an den Service geben". Der gesamte Zustand (Ladezustand, Fehler, Ergebnisse) lebt im Service. Zwei Vorteile: der Zustand überlebt das Verlassen und Wiederbetreten der Seite, und der Service ist ohne DOM testbar.

Mini-Übung: Füge dem searchForm ein Feld category: '' hinzu, reich es in search() als weiteren HttpParams-Eintrag durch, und bau im Template ein <mat-form-field> dafür. Du fasst dabei Komponente, Service und Template an - der typische Dreiklang jeder Änderung hier.


Etappe 3 - Zustand mit Signals

Dateien: alle *.service.ts unter features/ außer catalog-admin/

Schau dir an: Das Muster ist in jedem Service identisch:

readonly items = signal<Xyz[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(null);

load(...): void {
  this.loading.set(true);
  this.http.get<...>(...).subscribe({
    next: (data) => { this.items.set(data); this.loading.set(false); },
    error: (err) => { this.error.set(this.extractErrorMessage(err)); this.loading.set(false); },
  });
}

Versteh warum: Für "lade eine Liste, zeig einen Spinner, zeig einen Fehler" ist ein Signal- Trio die kleinstmögliche Lösung. Kein Boilerplate. Der Preis: die Zustandsänderungen sind verstreut (jedes .set() ist eine eigene Stelle), es gibt kein Protokoll, kein Time-Travel- Debugging. Genau hier setzt Etappe 7 an.

Mini-Übung: Öffne die Angular DevTools, wähle eine Feature-Komponente, sieh dir im "Properties"-Tab den injizierten Service und seine Signal-Werte an. Führe eine Suche aus und beobachte, wie loading von true auf false springt.


Etappe 4 - Reactive Forms

Dateien: catalog-search.component.ts (einfach), members-admin.component.ts (mehrere Formulare, typisierte Werte, Validators)

Schau dir an:

Versteh warum: Der Formularzustand lebt im TypeScript-Code, nicht im Template. Du kannst ihn in einem Unit-Test ohne gerendertes DOM prüfen. Template-driven Forms ([(ngModel)]) wären kürzer zu tippen, aber der Zustand wäre implizit im DOM verstreut.

Mini-Übung: Der email-Validator zeigt im Template noch keine Fehlermeldung an. Ergänze @if (registerForm.controls.email.hasError('email')) { <mat-error>Keine gültige E-Mail</mat-error> }.


Etappe 5 - Login: OIDC Authorization Code Flow + PKCE

Dateien: src/app/core/auth/auth.config.ts, auth.service.ts, und der provideAppInitializer(...)-Aufruf in app.config.ts

Schau dir an:

Versteh warum: Der Flow ist: App → Keycloak-Login-Seite → zurück mit ?code=... → App tauscht Code (+ PKCE-Verifier) gegen Access-Token + ID-Token → Token liegt im Speicher, wird bei jedem API-Call mitgeschickt (Etappe 6).

Mini-Übung: Log dich ein, öffne die Browser-DevTools → Application → Session Storage. Du findest access_token und id_token. Kopiere das Access-Token in https://jwt.io und finde den realm_access.roles-Claim - das ist genau, was auth.service.ts ausliest.


Etappe 6 - HTTP-Interceptor und Route Guards (funktional)

Dateien: src/app/core/auth/auth.interceptor.ts, src/app/core/auth/role.guard.ts

Schau dir an:

Versteh warum (wichtig für Reviewer:innen): Der roleGuard ist eine UX-Maßnahme, keine Sicherheitsgrenze. Er blendet Menüpunkte aus. Die echte Autorisierung erzwingen ausschließlich die Backend-Services per @PreAuthorize (common-security). Ein Frontend-Guard ist mit den Browser-DevTools trivial zu umgehen.

Mini-Übung: Log dich als einfaches Mitglied ein (kein LIBRARIAN). Die Menüpunkte "Bestand verwalten" / "Mitglieder" fehlen. Tippe die URL /admin/books trotzdem von Hand ein - der roleGuard wirft dich auf /. Öffne dann das Backend-Log und ruf denselben Endpunkt direkt auf (curl ohne passende Rolle) - 403 vom Server. Zwei Ebenen, nur die zweite zählt.


Etappe 7 - Der Kontrast: NgRx in catalog-admin

Dateien: src/app/features/catalog-admin/store/ (*.actions.ts, *.reducer.ts, *.effects.ts, *.selectors.ts, *.state.ts) + catalog-admin.component.ts

Dieses eine Feature nutzt statt eines Signal-Services den vollen NgRx-Redux-Stack. Es tut fachlich fast dasselbe wie catalog.service.ts (Bücher laden, Buch anlegen, Exemplar hinzufügen)

Schau dir an:

Versteh warum / wann sich das lohnt: Der detaillierte Zeile-für-Zeile-Vergleich steht in signals-vs-ngrx.md. Kurzfassung: NgRx kostet vier zusätzliche Dateien und viel Zeremonie, gibt dir dafür ein durchsuchbares Aktionsprotokoll (Redux DevTools), erzwungene Trennung von "was passiert ist" und "wie der Zustand darauf reagiert", und einen klaren Ort für Seiteneffekt-Verkettung. Für eine simple Ladeliste ist das Overkill - für einen komplexen Workflow mit vielen voneinander abhängigen Zustandsübergängen zahlt es sich aus.

Mini-Übung: Installiere die Redux DevTools-Extension. Öffne /admin/books, leg ein Buch an. Im DevTools-Panel siehst du die Aktionssequenz: [Catalog Admin] Register Book[Catalog Admin] Register Book Success[Catalog Admin] Load Books[Catalog Admin] Load Books Success. Klick auf eine Aktion und sieh den Zustand davor/danach. Das gibt es bei den Signal-Services nicht.


Etappe 8 - Tests

Dateien: src/app/app.component.spec.ts, e2e/golden-path.spec.ts, e2e/README.md

Schau dir an:

Versteh warum: Die Unit-Test-Ebene des Frontends ist bewusst dünn (eine Shell-Spec) - der Fokus lag auf einem belastbaren E2E-Golden-Path gegen den realen Stack statt auf vielen isolierten Komponenten-Specs. Die Shell-Spec zeigt das Muster (Service-Double + Signal setzen + gerendertes DOM prüfen), an dem sich Feature-Specs orientieren können.


Vertiefungsaufgaben (größere Brocken, in aufsteigender Schwierigkeit)

  1. Eine Feature-Spec schreiben. app.component.spec.ts zeigt das Muster (Service-Double, Signal setzen, gerendertes DOM prüfen). Schreib nach demselben Schema einen Spec für catalog-search.component.ts: mit einem FakeCatalogService, dessen books/loading-Signals du im Test setzt, prüfst du "zeigt Spinner bei loading()", "zeigt Keine Bücher gefunden bei leerer Liste", "der Submit-Button ruft catalogService.search(...)".

  2. Fehlermeldungen im Template. Kein Feature zeigt derzeit feldbezogene Validierungsfehler an (nur den globalen error()-Banner). Ergänze <mat-error>-Blöcke für die Pflichtfeld-/E-Mail-Validators in members-admin und catalog-admin.

  3. Ein Feature von Signals nach NgRx portieren (oder umgekehrt: catalog-admin nach Signals). Nimm fines - es ist klein. Du baust fines.actions.ts, .reducer.ts, .effects.ts, .selectors.ts, .state.ts, registrierst sie in app.config.ts und machst die Komponente "dumm". Danach hast du beide Varianten desselben Features gebaut und weißt aus erster Hand, was der Mehraufwand ist.

  4. Keycloak-Nutzer ↔ Member-Id verknüpfen. Aktuell muss ein Mitglied seine Member-UUID von Hand eintippen (siehe Javadoc in my-loans.component.ts). Baue: ein keycloakUserId-Feld am Member-Aggregat (Backend), einen Lookup-Endpunkt GET /api/members/by-keycloak-id/{sub}, und im Frontend einen MemberContextService, der beim Login den sub-Claim nimmt, den Member auflöst und als Signal bereitstellt. Dann wird aus "Meine Ausleihen" tatsächlich meine.

  5. @defer ausprobieren. Die Ergebnistabellen könnten mit dem Angular-17+-@defer-Block verzögert geladen werden (@defer (on viewport) { <table>... } @placeholder { ... }). Miss mit den DevTools, ob es beim initialen Rendern etwas bringt.


Weiterführend

⌂ Cockpit