Angular-Konzepte in diesem Projekt (Nachschlagewerk)

Zielgruppe: Entwickler:in, die ein bestimmtes Angular-Konzept schnell an diesem Code nachschlagen will. Der Frontend-Lernpfad geht dieselben Themen in Lese-Reihenfolge mit Übungen durch - dieses Dokument ist die alphabetisch/thematisch sortierte Referenz. Jeder Abschnitt: was es ist, wo im Projekt, klassischer Angular-Weg zum Vergleich. Wenn dein „klassischer Angular-Weg" noch aus v8-Zeiten stammt: der Exkurs: Angular 8 → 22 erzählt die Entwicklung narrativ und mit dem Warum.

Auf einen Blick

Konzept In diesem Projekt Klassischer / älterer Weg
App-Struktur Standalone Components, kein NgModule AppModule + Feature-NgModules
Bootstrap bootstrapApplication(AppComponent, appConfig) platformBrowserDynamic().bootstrapModule(AppModule)
Provider-Konfiguration ApplicationConfig mit provideXxx()-Funktionen @NgModule({ providers, imports })
App-Initialisierung provideAppInitializer(() => inject(...)...) APP_INITIALIZER-Multi-Provider mit deps
Dependency Injection inject(Service) als Feld-Initialisierer Konstruktor-Parameter mit private
Zustandsverwaltung Angular Signals (Standard) / NgRx (nur catalog-admin) RxJS BehaviorSubject + async-Pipe
Routing flache Routes mit loadComponent loadChildren auf Feature-Module
Route Guards funktionale CanActivateFn (+ Guard-Factory) @Injectable implements CanActivate
HTTP-Interceptor funktionale HttpInterceptorFn @Injectable implements HttpInterceptor
Templates - Bedingungen/Schleifen @if / @else if / @for *ngIf / *ngFor (+ CommonModule)
Formulare Reactive Forms, nonNullable Template-driven ([(ngModel)])
Change Detection zonen-basiert, eventCoalescing: true zonen-basiert (Default), Zoneless (neuer)
UI-Komponenten Angular Material 22 -
Auth angular-oauth2-oidc, Code Flow + PKCE -

Standalone Components

Was: Eine Komponente deklariert ihre Template-Abhängigkeiten selbst im imports-Array des @Component-Decorators. Es gibt kein NgModule, das Komponenten "bündelt und bereitstellt".

Wo: Jede Komponente. Beispiel catalog-search.component.ts:

@Component({
  selector: 'app-catalog-search',
  imports: [ReactiveFormsModule, MatFormFieldModule, MatInputModule, /* ... */],
  templateUrl: './catalog-search.component.html',
})
export class CatalogSearchComponent { }

Klassischer Weg: Ein CatalogModule mit declarations: [CatalogSearchComponent], imports: [CommonModule, ReactiveFormsModule, ...], exports: [...]. Die Standalone-Variante spart diese Indirektion. Bis Angular 18 musste standalone: true im Decorator stehen; ab Angular 19 ist standalone der Default - beim Update auf v19 hat eine Migrations-Schematic die Angabe aus allen Komponenten dieses Projekts entfernt (siehe angular-update-migration.md).


Bootstrap und ApplicationConfig

Was: main.ts ruft bootstrapApplication(RootComponent, config). Die config ist ein Objekt { providers: [...] } - die einzige Stelle für app-weite Provider.

Wo: src/main.ts (5 Zeilen), src/app/app.config.ts (die Provider-Liste).

export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideRouter(routes),
    provideAnimations(),
    provideHttpClient(withInterceptors([authInterceptor])),
    importProvidersFrom(OAuthModule.forRoot()),
    provideStore({ catalogAdmin: catalogAdminReducer }),
    provideEffects([CatalogAdminEffects]),
    provideStoreDevtools({ maxAge: 25, logOnly: !isDevMode() }),
    provideAppInitializer(() => inject(AuthService).initialize()),
  ],
};

Detail importProvidersFrom(OAuthModule.forRoot()): angular-oauth2-oidc bietet (noch) nur eine NgModule-API. importProvidersFrom ist die Brücke, um deren Provider in eine Standalone-ApplicationConfig zu ziehen.

Detail provideAppInitializer: Nimmt eine Funktion, die (optional) ein Promise zurückgibt. Angular verzögert den Bootstrap (und damit die erste Router-Navigation), bis das Promise auflöst. Die Funktion läuft im Injection-Kontext - daher inject(AuthService) direkt darin, ohne deps-Liste. Hier: der Router darf keine Route aktivieren, bevor AuthService.initialize() den Login-Zustand aus Keycloak geladen hat.


Dependency Injection mit inject()

Was: inject(Token) holt eine Abhängigkeit aus dem DI-Container. Funktioniert überall in einem "Injection Context": Feld-Initialisierer, Konstruktor, provideX-Factories, funktionale Guards/Interceptors, provideAppInitializer-Callbacks, runInInjectionContext.

Wo: Durchgängig. catalog.service.ts: private readonly http = inject(HttpClient);. auth.interceptor.ts: const authService = inject(AuthService); - hier geht der Konstruktor-Weg gar nicht, weil der Interceptor eine Funktion ist, keine Klasse. Ebenso app.config.ts: provideAppInitializer(() => inject(AuthService).initialize()).

Klassischer Weg: constructor(private http: HttpClient) {}. Weiterhin gültig; inject() ist kompakter und die einzige Option in funktionalen Bausteinen. Vergleiche mit der Backend-Coding-Guideline "Konstruktor-Injection statt Feld-Injection" - im Frontend ist inject() als Feld-Initialisierer der Normalfall, weil es dort keine Reflection-basierte Feld-Injection wie bei Spring gibt (die Abhängigkeit ist sichtbar und readonly).


Signals

Was: Ein signal(initialValue) ist ein Container für einen Wert, den Leser automatisch "abonnieren". sig() liest, sig.set(v) / sig.update(fn) schreibt. Ein Lesezugriff im Template registriert die Komponente als abhängig - ändert sich das Signal, rendert Angular neu.

Wo: Jeder Feature-Service außer catalog-admin. Muster:

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

auth.service.ts gibt seinen ganzen Zustand als Signals nach außen (isLoggedIn, roles, username), obwohl die Quelle darunter ein RxJS-Observable (oauthService.events) ist - der Service übersetzt Observable → Signal.

Verwandt:

Klassischer Weg: readonly books$ = new BehaviorSubject<BookResponse[]>([]) + im Template books$ | async. Nachteil: async-Pipe-Boilerplate, Nullable-Werte beim ersten Tick, Verschachtelung bei mehreren Streams.


Templates: @if, @for, Control Flow

Was: Seit Angular 17 gibt es eingebaute Block-Syntax für Kontrollfluss - keine Struktur-Direktiven, kein CommonModule-Import.

Wo: app.component.html:

@if (authService.isLoggedIn()) {
  <router-outlet />
} @else {
  <div class="login-hint"> ... </div>
}

catalog-search.component.html nutzt die dreistufige Kette @if (loading()) { ... } @else if (books().length === 0) { ... } @else { <table> ... }.

Klassischer Weg: *ngIf="isLoggedIn(); else loginHint" mit <ng-template #loginHint>, und *ngFor="let x of items()". Funktioniert weiter, braucht aber CommonModule in imports und ist bei else/elseif deutlich umständlicher.

Noch nicht genutzt, aber verwandt: @for (item of items(); track item.id) { } (das track ist Pflicht), @switch, @defer (verzögertes Laden von Template-Teilen).


Routing und Lazy Loading

Was: Routes ist ein flaches Array. loadComponent lädt pro Route ein eigenes JS-Chunk.

Wo: app.routes.ts:

{
  path: 'admin/books',
  canActivate: [authGuard, roleGuard(['LIBRARY_ADMIN', 'LIBRARIAN'])],
  loadComponent: () => import('./features/catalog-admin/catalog-admin.component')
                          .then((m) => m.CatalogAdminComponent),
  title: 'Bestand verwalten',
}

title wird von Angulars TitleStrategy automatisch in den document.title geschrieben. Fallback-Routen am Ende: { path: '', redirectTo: 'catalog' }, { path: '**', redirectTo: 'catalog' }.

Klassischer Weg: loadChildren: () => import('./catalog/catalog.module').then(m => m.CatalogModule) - das Feature-Modul brachte seine eigene RouterModule.forChild([...])-Tabelle mit. Mit Standalone Components entfällt diese zweite Ebene.


Route Guards (funktional + Guard-Factory)

Was: Ein Guard ist eine Funktion CanActivateFn, die true, false oder ein UrlTree (Redirect) zurückgibt.

Wo: role.guard.ts:

export function roleGuard(requiredRoles: string[]): CanActivateFn {
  return () => {
    const authService = inject(AuthService);
    const router = inject(Router);
    if (authService.hasAnyRole(...requiredRoles)) return true;
    return router.createUrlTree(['/']);
  };
}

export const authGuard: CanActivateFn = () => inject(AuthService).isLoggedIn();

roleGuard(...) ist eine Factory - sie erzeugt eine passende CanActivateFn pro Rollen-Liste, statt eine Guard-Klasse je Kombination zu brauchen.

Klassischer Weg: @Injectable({providedIn:'root'}) class RoleGuard implements CanActivate { canActivate(route) {...} } - eine parametrisierte Guard-Klasse ging nur über route.data und war sperriger.

Wichtig: Beide Guards sind UX, keine Sicherheit - siehe das Javadoc in der Datei und den Frontend-Lernpfad, Etappe 6. Autorisierung erzwingt das Backend (@PreAuthorize).


HTTP-Client und Interceptor

Was: provideHttpClient(withInterceptors([fn])) registriert funktionale Interceptors. Ein HttpInterceptorFn bekommt (req, next) und gibt next(req) zurück - HttpRequest ist immutable, Änderungen über req.clone(...).

Wo: auth.interceptor.ts:

export const authInterceptor: HttpInterceptorFn = (request, next) => {
  const authService = inject(AuthService);
  if (!request.url.startsWith(API_BASE_URL)) return next(request);
  const token = authService.getAccessToken();
  if (!token) return next(request);
  return next(request.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
};

Spiegelbild zum Backend: lending-service hat einen TokenRelayFeignInterceptor, der das Token von Service zu Service weiterreicht - hier wird es ursprünglich angehängt.

Klassischer Weg: @Injectable() class AuthInterceptor implements HttpInterceptor, registriert über { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }.

HTTP-Aufrufe selbst: catalog.service.ts zeigt HttpParams (Query-String bauen) und getippte Antworten this.http.get<PageResponse<BookResponse>>(url, { params }). Die Signal-Services rufen .subscribe(...) direkt; die NgRx-Effects nutzen den Stream mit map/catchError-Operatoren.


Reactive Forms

Was: Der Formularzustand lebt als FormGroup/FormControl-Objekt im TypeScript-Code. Das Template bindet nur über [formGroup] und formControlName.

Wo: members-admin.component.ts (das reichhaltigste Beispiel):

protected readonly registerForm = this.formBuilder.nonNullable.group({
  firstName: ['', Validators.required],
  email: ['', [Validators.required, Validators.email]],
  tier: ['STANDARD' as (typeof this.tiers)[number], Validators.required],
});

Klassischer Weg: Template-driven mit [(ngModel)] und #form="ngForm". Kürzer zu tippen, aber der Zustand ist implizit im DOM und schwerer isoliert zu testen. Dieses Projekt nutzt Template-driven nie.


Change Detection

Was: provideZoneChangeDetection({ eventCoalescing: true }) in app.config.ts. Angular nutzt hier weiterhin zone.js (package.json: zone.js ~0.16), um DOM-Events abzufangen und danach neu zu rendern. eventCoalescing fasst mehrere Events im selben Tick zu einem Render-Durchlauf zusammen.

Relevanz für dich: Weil der Zustand über Signals läuft, ist Change Detection meist kein Thema - ein signal.set() markiert die abhängigen Komponenten präzise. ChangeDetectionStrategy. OnPush wird hier nicht explizit gesetzt (mit reiner Signal-Nutzung wäre es der logische nächste Schritt und eine sinnvolle Übung).

Neuer Weg (nicht in diesem Projekt): Zoneless Change Detection - provideZonelessChangeDetection() (in Angular 18 noch provideExperimentalZonelessChangeDetection, ab v20 stabilisiert und umbenannt). Kommt ohne zone.js aus und verlässt sich ganz auf Signals. Dieses Projekt bleibt bewusst beim etablierten zonen-basierten Modell - die Umstellung ist ein eigenes Vorhaben, kein Update-Nebeneffekt.


Angular Material

Was: @angular/material + @angular/cdk (beide ^22.1). Fertige UI-Komponenten: MatToolbar, MatTable, MatFormField, MatInput, MatButton, MatChips, MatSelect, MatProgressSpinner, MatCheckbox.

Wo: Jede Komponente importiert die Mat*Module, die ihr Template braucht. Die Tabelle in catalog-search.component.html zeigt das mat-table-Muster mit matColumnDef/matHeaderCellDef/ matCellDef und displayedColumns (als Feld in der Komponente).

provideAnimations() in app.config.ts ist Voraussetzung für Material-Übergänge (Ripple-Effekte, Spinner, Menü-Animationen).


Auth: OIDC Authorization Code Flow + PKCE

Was: angular-oauth2-oidc (^19.0.0). Der Browser ist ein "public client" ohne Client-Secret; PKCE sichert den Code-Austausch ab.

Wo:

Siehe auch: Glossar ("Authorization Code Flow + PKCE"), ADR-0004 (Token-Lebensdauer), ADR-0005 (Resource-Server-Setup Backend).


Was dieses Projekt bewusst NICHT nutzt

Nicht genutzt Stattdessen Grund
NgModule Standalone Components Weniger Indirektion, ab v19 Angular-Default
Template-driven Forms Reactive Forms Testbarer, expliziter Zustand
*ngIf / *ngFor @if / @for Kein CommonModule, bessere else-Syntax
Klassenbasierte Guards/Interceptors funktionale Variante Kürzer, inject()-fähig
APP_INITIALIZER-Multi-Provider provideAppInitializer() Kürzer, inject()-fähig (ab v19)
NgRx überall Signals als Standard NgRx nur in catalog-admin als Lern-Kontrast
Zoneless CD zonen-basierte CD bewusst konservativ, eigene Umstellung wert
Eigenes State-Framework Signals / NgRx Kein "Framework im Framework"
Frontend-eigenes Datenmodell *.model.ts spiegelt Backend-DTOs 1:1 Keine Übersetzungsschicht, eine Quelle der Wahrheit
⌂ Cockpit