16 Services und Dependency Injection

16.1 Die Trennung von Zuständigkeiten

Angular-Komponenten sollten sich auf Präsentationslogik konzentrieren – das Rendern der Benutzeroberfläche, das Reagieren auf Benutzerinteraktionen, das Koordinieren von Child-Komponenten. Geschäftslogik, Datenzugriff, Validierung, Logging und andere übergreifende Aufgaben gehören nicht in Komponenten. Services lösen dieses Problem durch klare Trennung der Verantwortlichkeiten.

Ein Service ist eine TypeScript-Klasse mit einem spezifischen Zweck. Sie kapselt Funktionalität, die von mehreren Komponenten genutzt wird, und macht diese über Angular’s Dependency Injection-System verfügbar. Diese Architektur führt zu schlankeren Komponenten, besserem Testbarkeit und höherer Wiederverwendbarkeit.

Der Unterschied wird an einem Beispiel deutlich. Eine Komponente ohne Service vermischt Präsentation und Datenlogik:

export class ProductListComponent {
  products: Product[] = [];
  loading = false;
  error: string | null = null;
  
  loadProducts() {
    this.loading = true;
    this.error = null;
    
    fetch('https://api.example.com/products')
      .then(response => response.json())
      .then(data => {
        this.products = data;
        this.loading = false;
      })
      .catch(err => {
        this.error = err.message;
        this.loading = false;
      });
  }
}

Mit einem Service bleibt die Komponente fokussiert:

export class ProductListComponent {
  products$ = this.productService.getProducts();
  
  constructor(private productService: ProductService) {}
}

Die Datenlogik wandert in den Service:

@Injectable({
  providedIn: 'root'
})
export class ProductService {
  private apiUrl = 'https://api.example.com/products';
  
  constructor(private http: HttpClient) {}
  
  getProducts(): Observable<Product[]> {
    return this.http.get<Product[]>(this.apiUrl);
  }
}

Diese Separation macht den Code wartbarer, testbarer und wiederverwendbarer. Der Service kann in beliebig vielen Komponenten verwendet werden, Tests können Services isoliert prüfen, und Komponenten bleiben schlank.

16.2 Dependency Injection verstehen

Dependency Injection ist ein Design Pattern, bei dem Abhängigkeiten von außen bereitgestellt werden, statt sie intern zu erzeugen. Angular implementiert dieses Pattern durch ein ausgefeiltes Injector-System, das zur Laufzeit Instanzen verwaltet und bereitstellt.

Das traditionelle Problem ohne Dependency Injection:

export class ReportComponent {
  private dataService = new DataService();
  private logService = new LogService();
  
  generateReport() {
    const data = this.dataService.getData();
    this.logService.log('Report generated');
    // ...
  }
}

Diese Komponente ist fest an konkrete Implementierungen gekoppelt. Tests müssen mit echten Services arbeiten, und Änderungen an Service-Konstruktoren brechen die Komponente.

Mit Dependency Injection:

export class ReportComponent {
  constructor(
    private dataService: DataService,
    private logService: LogService
  ) {}
  
  generateReport() {
    const data = this.dataService.getData();
    this.logService.log('Report generated');
    // ...
  }
}

Angular injiziert die Services automatisch. Tests können Mock-Implementierungen bereitstellen, und die Komponente bleibt unabhängig von Implementierungsdetails.

Der Injector-Baum ist hierarchisch strukturiert:

Angular durchsucht diese Hierarchie von unten nach oben. Ein Service, der auf Komponenten-Ebene registriert ist, überschreibt Module-Level-Provider, die wiederum Root-Level-Provider überschreiben.

16.3 Service-Erstellung und Konfiguration

Die Angular CLI generiert Services mit einem einfachen Befehl:

ng generate service data

Dies erzeugt zwei Dateien: data.service.ts mit der Implementierung und data.service.spec.ts für Tests. Die grundlegende Struktur:

import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root'
})
export class DataService {
  constructor() {}
}

Der @Injectable-Dekorator markiert die Klasse als Service. Die Eigenschaft providedIn steuert die Verfügbarkeit und Lebensdauer.

16.3.1 Provider-Scopes verstehen

Die providedIn-Konfiguration bestimmt, wo und wie der Service verfügbar ist:

Scope Konfiguration Verhalten Verwendung
Root providedIn: 'root' Singleton, anwendungsweit Standard für geteilten State
Platform providedIn: 'platform' Geteilt zwischen allen Apps Multi-App-Szenarien
Any providedIn: 'any' Instanz pro Lazy-Loaded Module Modulspezifischer State
Specific providedIn: SomeModule Nur im angegebenen Modul Legacy-Module

Root-Level ist die Standardwahl. Der Service wird einmal erstellt und von allen Komponenten geteilt. Dies ist ideal für State-Management, Caching und API-Kommunikation:

@Injectable({
  providedIn: 'root'
})
export class AuthService {
  private currentUser: User | null = null;
  
  login(credentials: Credentials) {
    // Login-Logik
  }
  
  isAuthenticated(): boolean {
    return this.currentUser !== null;
  }
}

Alle Komponenten teilen dieselbe AuthService-Instanz und damit denselben currentUser-State.

Any-Scope erstellt separate Instanzen für jedes Lazy-Loaded Module:

@Injectable({
  providedIn: 'any'
})
export class FeatureStateService {
  private state = new Map<string, any>();
  
  setState(key: string, value: any) {
    this.state.set(key, value);
  }
}

Das Admin-Module erhält eine eigene Instanz, das Dashboard-Module eine andere. State bleibt modulspezifisch isoliert.

16.4 Services in Komponenten verwenden

Die Injektion erfolgt über den Konstruktor:

import { Component, OnInit } from '@angular/core';
import { ProductService } from './product.service';

@Component({
  selector: 'app-product-list',
  template: `
    <div *ngFor="let product of products">
      {{ product.name }} - {{ product.price | currency }}
    </div>
  `
})
export class ProductListComponent implements OnInit {
  products: Product[] = [];
  
  constructor(private productService: ProductService) {}
  
  ngOnInit() {
    this.productService.getProducts().subscribe(
      products => this.products = products
    );
  }
}

Das private im Konstruktor ist TypeScript-Shorthand. Es deklariert und initialisiert die Eigenschaft in einem Schritt. Äquivalent wäre:

export class ProductListComponent {
  private productService: ProductService;
  
  constructor(productService: ProductService) {
    this.productService = productService;
  }
}

Die Shorthand-Syntax ist prägnanter und idiomatischer für Angular-Code.

16.4.1 Die moderne inject() Funktion

Angular 14 führte die inject()-Funktion ein, die Dependency Injection außerhalb von Konstruktoren ermöglicht:

import { Component } from '@angular/core';
import { inject } from '@angular/core';
import { ProductService } from './product.service';

@Component({
  selector: 'app-product-list',
  template: '...'
})
export class ProductListComponent {
  private productService = inject(ProductService);
  products$ = this.productService.getProducts();
}

Diese Syntax ist besonders nützlich für abgeleitete Werte:

export class DashboardComponent {
  private authService = inject(AuthService);
  private router = inject(Router);
  
  canAccessAdmin = this.authService.hasRole('admin');
  
  navigateTo(route: string) {
    this.router.navigate([route]);
  }
}

Die inject()-Funktion kann nur im Injection Context aufgerufen werden – während der Konstruktion von Komponenten, Direktiven, Pipes oder Services. Sie funktioniert nicht in Lifecycle-Hooks oder regulären Methoden.

16.5 Komponenten-spezifische Service-Instanzen

Standardmäßig teilen sich alle Komponenten dieselbe Service-Instanz. Manchmal ist jedoch eine separate Instanz pro Komponente gewünscht:

@Component({
  selector: 'app-form',
  providers: [FormStateService],
  template: '...'
})
export class FormComponent {
  constructor(private formState: FormStateService) {}
}

Der providers-Array im Component-Dekorator registriert einen Provider auf Komponenten-Ebene. Jede Instanz von FormComponent erhält ihre eigene FormStateService-Instanz. Dies ist nützlich für isolierten State innerhalb von Komponenten-Hierarchien.

Child-Komponenten erben diesen Provider:

@Component({
  selector: 'app-parent',
  providers: [SharedService],
  template: `
    <app-child></app-child>
  `
})
export class ParentComponent {}

@Component({
  selector: 'app-child',
  template: '...'
})
export class ChildComponent {
  constructor(private shared: SharedService) {}
}

ParentComponent und ChildComponent teilen dieselbe SharedService-Instanz. Eine andere ParentComponent-Instanz erhält jedoch eine separate SharedService.

Die Provider-Hierarchie beeinflusst die Auflösung:

// Root-Level: Eine Instanz für die gesamte App
@Injectable({ providedIn: 'root' })
export class RootService {}

// Component-Level: Eine Instanz pro Komponente
@Component({
  providers: [ComponentService]
})
export class MyComponent {}

Angular sucht bottom-up. Wenn eine Komponente einen Service anfordert, prüft Angular zuerst den Komponenten-Injector, dann Parent-Injectors, schließlich den Root-Injector.

16.6 HTTP-Services und API-Kommunikation

Ein häufiger Service-Typ ist der HTTP-Service für API-Kommunikation. Der HttpClient wird injiziert und für Requests verwendet:

import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map, catchError, retry } from 'rxjs/operators';

interface User {
  id: number;
  name: string;
  email: string;
}

@Injectable({
  providedIn: 'root'
})
export class UserService {
  private apiUrl = 'https://api.example.com/users';
  
  constructor(private http: HttpClient) {}
  
  getUsers(): Observable<User[]> {
    return this.http.get<User[]>(this.apiUrl).pipe(
      retry(3),
      catchError(this.handleError)
    );
  }
  
  getUser(id: number): Observable<User> {
    return this.http.get<User>(`${this.apiUrl}/${id}`);
  }
  
  searchUsers(query: string): Observable<User[]> {
    const params = new HttpParams().set('q', query);
    return this.http.get<User[]>(`${this.apiUrl}/search`, { params });
  }
  
  createUser(user: Omit<User, 'id'>): Observable<User> {
    return this.http.post<User>(this.apiUrl, user);
  }
  
  updateUser(user: User): Observable<User> {
    return this.http.put<User>(`${this.apiUrl}/${user.id}`, user);
  }
  
  deleteUser(id: number): Observable<void> {
    return this.http.delete<void>(`${this.apiUrl}/${id}`);
  }
  
  private handleError(error: any): Observable<never> {
    console.error('API Error:', error);
    throw error;
  }
}

Der Service kapselt alle API-Interaktionen. Komponenten konsumieren Observables, ohne HTTP-Details zu kennen:

export class UserListComponent implements OnInit {
  users$ = this.userService.getUsers();
  
  constructor(private userService: UserService) {}
  
  ngOnInit() {
    // Optional: Eager Loading
    this.users$.subscribe();
  }
}

Das Template verwendet die Async-Pipe für automatisches Subscription-Management:

<div *ngFor="let user of users$ | async">
  {{ user.name }} ({{ user.email }})
</div>

Die Async-Pipe subscribed automatisch, handled Unsubscribe bei Component-Destruction und triggert Change Detection bei neuen Werten.

16.7 Service-zu-Service-Kommunikation

Services können andere Services injizieren, um Funktionalität zu komponieren:

@Injectable({
  providedIn: 'root'
})
export class LogService {
  log(message: string) {
    console.log(`[LOG] ${new Date().toISOString()}: ${message}`);
  }
  
  error(message: string, error?: any) {
    console.error(`[ERROR] ${new Date().toISOString()}: ${message}`, error);
  }
}

@Injectable({
  providedIn: 'root'
})
export class DataService {
  private apiUrl = 'https://api.example.com/data';
  
  constructor(
    private http: HttpClient,
    private logService: LogService
  ) {}
  
  fetchData(): Observable<any[]> {
    this.logService.log('Fetching data from API');
    
    return this.http.get<any[]>(this.apiUrl).pipe(
      tap(data => {
        this.logService.log(`Received ${data.length} items`);
      }),
      catchError(error => {
        this.logService.error('Failed to fetch data', error);
        throw error;
      })
    );
  }
}

Diese Komposition ermöglicht Cross-Cutting Concerns wie Logging, Caching oder Authentication durch dedizierte Services.

Ein komplexeres Beispiel zeigt mehrschichtige Service-Architektur:

@Injectable({
  providedIn: 'root'
})
export class CacheService {
  private cache = new Map<string, { data: any, timestamp: number }>();
  private ttl = 5 * 60 * 1000; // 5 Minuten
  
  get(key: string): any | null {
    const entry = this.cache.get(key);
    if (!entry) return null;
    
    if (Date.now() - entry.timestamp > this.ttl) {
      this.cache.delete(key);
      return null;
    }
    
    return entry.data;
  }
  
  set(key: string, data: any) {
    this.cache.set(key, { data, timestamp: Date.now() });
  }
  
  clear() {
    this.cache.clear();
  }
}

@Injectable({
  providedIn: 'root'
})
export class ApiService {
  constructor(
    private http: HttpClient,
    private cacheService: CacheService,
    private logService: LogService
  ) {}
  
  getData(endpoint: string): Observable<any> {
    const cached = this.cacheService.get(endpoint);
    if (cached) {
      this.logService.log(`Cache hit for ${endpoint}`);
      return of(cached);
    }
    
    this.logService.log(`Cache miss for ${endpoint}, fetching from API`);
    return this.http.get(endpoint).pipe(
      tap(data => this.cacheService.set(endpoint, data))
    );
  }
}

Der ApiService nutzt CacheService für Performance und LogService für Observability. Diese Schichten bleiben unabhängig testbar.

16.8 Reaktive Services mit RxJS

Services und RxJS bilden ein mächtiges Pattern für State-Management. Ein Subject hält den State, ein Observable exponiert ihn:

import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';

interface AppState {
  loading: boolean;
  data: any[] | null;
  error: string | null;
}

@Injectable({
  providedIn: 'root'
})
export class StateService {
  private initialState: AppState = {
    loading: false,
    data: null,
    error: null
  };
  
  private stateSubject = new BehaviorSubject<AppState>(this.initialState);
  public state$ = this.stateSubject.asObservable();
  
  private updateState(partial: Partial<AppState>) {
    this.stateSubject.next({
      ...this.stateSubject.value,
      ...partial
    });
  }
  
  setLoading(loading: boolean) {
    this.updateState({ loading });
  }
  
  setData(data: any[]) {
    this.updateState({ 
      data, 
      loading: false, 
      error: null 
    });
  }
  
  setError(error: string) {
    this.updateState({ 
      error, 
      loading: false 
    });
  }
  
  reset() {
    this.stateSubject.next(this.initialState);
  }
}

Das BehaviorSubject speichert den aktuellen State und emittiert ihn sofort bei Subscription. Das öffentliche Observable ist read-only – nur der Service kann den State mutieren.

Komponenten konsumieren den State reaktiv:

export class DataViewComponent {
  state$ = this.stateService.state$;
  
  loading$ = this.state$.pipe(map(state => state.loading));
  data$ = this.state$.pipe(map(state => state.data));
  error$ = this.state$.pipe(map(state => state.error));
  
  constructor(
    private stateService: StateService,
    private apiService: ApiService
  ) {}
  
  loadData() {
    this.stateService.setLoading(true);
    
    this.apiService.getData().subscribe({
      next: data => this.stateService.setData(data),
      error: err => this.stateService.setError(err.message)
    });
  }
}

Das Template bindet an die Observables:

<div *ngIf="loading$ | async" class="spinner">Laden...</div>

<div *ngIf="error$ | async as error" class="error">
  Fehler: {{ error }}
</div>

<div *ngIf="data$ | async as data">
  <div *ngFor="let item of data">
    {{ item.name }}
  </div>
</div>

Dieses Pattern skaliert gut für komplexe State-Requirements. Alle State-Mutationen laufen durch den Service, was Debugging und Testing vereinfacht.

16.9 Signal-basierte Services

Angular Signals bieten eine Alternative zu Observable-basiertem State-Management:

import { Injectable, signal, computed } from '@angular/core';

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

@Injectable({
  providedIn: 'root'
})
export class TodoService {
  private todosSignal = signal<Todo[]>([]);
  
  todos = this.todosSignal.asReadonly();
  
  completedCount = computed(() => 
    this.todosSignal().filter(t => t.completed).length
  );
  
  activeCount = computed(() => 
    this.todosSignal().filter(t => !t.completed).length
  );
  
  addTodo(title: string) {
    const newTodo: Todo = {
      id: Date.now(),
      title,
      completed: false
    };
    
    this.todosSignal.update(todos => [...todos, newTodo]);
  }
  
  toggleTodo(id: number) {
    this.todosSignal.update(todos =>
      todos.map(todo =>
        todo.id === id 
          ? { ...todo, completed: !todo.completed }
          : todo
      )
    );
  }
  
  deleteTodo(id: number) {
    this.todosSignal.update(todos =>
      todos.filter(todo => todo.id !== id)
    );
  }
}

Komponenten nutzen Signals direkt:

export class TodoListComponent {
  private todoService = inject(TodoService);
  
  todos = this.todoService.todos;
  completedCount = this.todoService.completedCount;
  activeCount = this.todoService.activeCount;
  
  onToggle(id: number) {
    this.todoService.toggleTodo(id);
  }
}

Im Template:

<div>
  Aktiv: {{ activeCount() }} | Erledigt: {{ completedCount() }}
</div>

<ul>
  <li *ngFor="let todo of todos()">
    <input 
      type="checkbox" 
      [checked]="todo.completed"
      (change)="onToggle(todo.id)">
    {{ todo.title }}
  </li>
</ul>

Signals bieten feinkörnigere Reaktivität als Observables. Angular aktualisiert nur die tatsächlich betroffenen Template-Teile, nicht die gesamte Komponente.

16.10 Testing von Services

Angular’s TestBed vereinfacht Service-Tests erheblich:

import { TestBed } from '@angular/core/testing';
import { DataService } from './data.service';

describe('DataService', () => {
  let service: DataService;

  beforeEach(() => {
    TestBed.configureTestingModule({});
    service = TestBed.inject(DataService);
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });

  it('should add data correctly', () => {
    service.addData('Item 1');
    service.addData('Item 2');
    
    const data = service.getData();
    expect(data.length).toBe(2);
    expect(data).toContain('Item 1');
    expect(data).toContain('Item 2');
  });
});

Für Services mit Dependencies werden Mocks bereitgestellt:

import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { UserService } from './user.service';

describe('UserService', () => {
  let service: UserService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [UserService]
    });
    
    service = TestBed.inject(UserService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    httpMock.verify();
  });

  it('should fetch users', () => {
    const mockUsers = [
      { id: 1, name: 'User 1', email: 'user1@example.com' },
      { id: 2, name: 'User 2', email: 'user2@example.com' }
    ];

    service.getUsers().subscribe(users => {
      expect(users.length).toBe(2);
      expect(users).toEqual(mockUsers);
    });

    const req = httpMock.expectOne('https://api.example.com/users');
    expect(req.request.method).toBe('GET');
    req.flush(mockUsers);
  });
});

Das HttpClientTestingModule mockt HTTP-Requests. Der HttpTestingController verifiziert Requests und liefert Mock-Responses.

Für komplexere Dependencies verwendet man Spies:

describe('DataService with LogService', () => {
  let dataService: DataService;
  let logService: jasmine.SpyObj<LogService>;

  beforeEach(() => {
    const logSpy = jasmine.createSpyObj('LogService', ['log', 'error']);

    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [
        DataService,
        { provide: LogService, useValue: logSpy }
      ]
    });

    dataService = TestBed.inject(DataService);
    logService = TestBed.inject(LogService) as jasmine.SpyObj<LogService>;
  });

  it('should log when fetching data', () => {
    dataService.fetchData().subscribe();
    
    expect(logService.log).toHaveBeenCalledWith('Fetching data from API');
  });
});

Der Spy-Mock erlaubt Verifikation, dass bestimmte Methoden aufgerufen wurden.

16.11 Fortgeschrittene Patterns

16.11.1 Factory Providers

Factory-Funktionen erstellen Service-Instanzen mit komplexer Logik:

export function createConfigService(http: HttpClient): ConfigService {
  const config = new ConfigService(http);
  config.load();
  return config;
}

@NgModule({
  providers: [
    {
      provide: ConfigService,
      useFactory: createConfigService,
      deps: [HttpClient]
    }
  ]
})
export class AppModule {}

Die Factory wird bei der ersten Anforderung ausgeführt und kann Dependencies injizieren.

16.11.2 Value Providers

Konstanten oder Konfigurationsobjekte als Injectable:

export const API_CONFIG = new InjectionToken<ApiConfig>('api.config');

interface ApiConfig {
  baseUrl: string;
  timeout: number;
}

@NgModule({
  providers: [
    {
      provide: API_CONFIG,
      useValue: {
        baseUrl: 'https://api.example.com',
        timeout: 30000
      }
    }
  ]
})
export class AppModule {}

Services injizieren die Konfiguration:

@Injectable({
  providedIn: 'root'
})
export class ApiService {
  constructor(@Inject(API_CONFIG) private config: ApiConfig) {
    console.log(`API Base URL: ${this.config.baseUrl}`);
  }
}

InjectionToken ermöglicht typsichere Injection von Nicht-Klassen-Werten.

16.11.3 Multi-Providers

Mehrere Implementierungen derselben Abhängigkeit:

export const INIT_SERVICE = new InjectionToken<InitService>('init.service');

@Injectable({ providedIn: 'root' })
export class DatabaseInitService {
  init() {
    console.log('Database initialized');
  }
}

@Injectable({ providedIn: 'root' })
export class CacheInitService {
  init() {
    console.log('Cache initialized');
  }
}

@NgModule({
  providers: [
    { provide: INIT_SERVICE, useClass: DatabaseInitService, multi: true },
    { provide: INIT_SERVICE, useClass: CacheInitService, multi: true }
  ]
})
export class AppModule {
  constructor(@Inject(INIT_SERVICE) initServices: InitService[]) {
    initServices.forEach(service => service.init());
  }
}

Angular sammelt alle Provider für das Token in einem Array. Dies ist nützlich für Plugin-Architekturen oder Hook-Systeme.

Services sind das Rückgrat jeder Angular-Anwendung. Sie kapseln Logik, ermöglichen Wiederverwendung und Integration, und bilden durch Dependency Injection eine flexible, testbare Architektur. Die Kombination aus Services, RxJS und Signals bietet mächtige Werkzeuge für State-Management, API-Kommunikation und Cross-Cutting Concerns.