ViewModel aus mehreren Datenquellen
Ein ViewModel entsteht nicht immer aus genau einer API-Antwort.
Oft sieht die Realität eher so aus:
articlesauthorscategories → ArticleOverviewVmOder allgemeiner:
HauptdatenReferenzdatenZusatzdaten → UI-ProjektionFrüher hätte man dafür häufig forkJoin verwendet.
forkJoin({ articles: this.articleApi.loadArticles(), authors: this.authorApi.loadAuthors(), categories: this.categoryApi.loadCategories(),}).pipe( map(({ articles, authors, categories }) => toArticleOverviewViewModel({ articles, authors, categories, }), ),);Das war nicht falsch.
Für viele Fälle war das sogar ein sehr sauberer Ausdruck:
Starte mehrere Requests.Warte, bis alle fertig sind.Baue danach ein gemeinsames Ergebnis.Der Preis ist aber: Das Ergebnis entsteht erst, wenn alles da ist.
Das ist manchmal exakt richtig.
Aber nicht immer.
Mit Signals und computed können wir heute anders denken.
Nicht mehr primär:
Requests verheiraten → Ergebnis bauensondern:
Datenquellen als reaktive Inputs behandeln → ViewModel als Ableitung berechnen
Das klingt nach einem kleinen Unterschied.
In der Praxis verändert es aber die Architektur.
Worum es in diesem Artikel geht
Abschnitt betitelt „Worum es in diesem Artikel geht“Dieser Artikel baut auf dem Retrieve-Slice auf.
Das Drumherum bleibt identisch:
Infrastructure kapselt den Resource-LifecycleInfrastructure stellt sichere Source Signals bereitStore orchestriert Sources und ProjektionFacade exponiert ViewModelComponent rendertDer Unterschied ist nur:
Das ViewModel entsteht diesmal aus mehreren Datenquellen.
Wir behandeln hier bewusst nicht den kompletten Datenfluss.
Nicht Thema dieses Artikels:
- Command-Flows
- Events
- Navigation
- vollständige ACL-Implementierung
- DTO-Validierung im Detail
- Fehlerstrategie
- Caching
- Entity-Normalisierung
- Loading-UX im Hauptbeispiel
Die API-Grenze deuten wir nur an:
parse response → mapToDomainDer Fokus liegt auf der ViewModel-Projektion und auf der Frage:
Wann warte ich auf alle Datenquellen? Und wann baue ich ein partielles ViewModel, das sich später ergänzt?
Dabei trennen wir zwei Arten von Guards:
technischer Resource-Guard → gehört in die Infrastructure
Projektions-Guard → entscheidet, welche Sources für ein gültiges ViewModel ausreichenDiese Unterscheidung ist wichtig.
hasValue() schützt die technische Angular Resource.
Die Frage, ob articles, authors und categories für diese konkrete UI-Projektion ausreichen, ist dagegen Teil des ViewModel-Vertrags.
Das Beispiel
Abschnitt betitelt „Das Beispiel“Wir bauen eine Artikelübersicht.
Die Hauptliste kommt aus articles.
Die Autoren kommen aus authors.
Die Kategorien kommen aus categories.
articles → id, title, authorId, categoryId
authors → id, name
categories → id, labelDas UI möchte aber kein technisches Datenmodell.
Das UI möchte ein ViewModel:
ArticleOverviewVm → title → authorName → categoryLabelDie Zuordnung entsteht also aus drei Quellen:
article.authorId → authors.find(author.id)
article.categoryId → categories.find(category.id)
Infrastructure: drei gekapselte Sources
Abschnitt betitelt „Infrastructure: drei gekapselte Sources“Die Infrastructure bleibt bewusst dünn.
Sie lädt Daten, mappt externe Antworten an der API-Grenze in das Domain-Modell und kapselt den technischen Lifecycle der Angular Resource.
Die konkrete HttpResourceRef bleibt privat.
Nach außen gehen nur benannte, sichere Signals und explizite Operationen.
articles.resource.ts
Abschnitt betitelt „articles.resource.ts“import { computed, Injectable } from '@angular/core';import { httpResource } from '@angular/common/http';
import { Article } from '../entities/article.model';import { mapArticlesResponseToDomain } from './article.mapper';
@Injectable()export class ArticlesResource { private readonly resource = httpResource<readonly Article[]>( () => '/api/articles', { parse: mapArticlesResponseToDomain, }, );
readonly articles = computed<readonly Article[] | null>(() => { if (!this.resource.hasValue()) { return null; }
return this.resource.value(); });
readonly isLoading = this.resource.isLoading; readonly error = this.resource.error;
reload(): void { this.resource.reload(); }}authors.resource.ts
Abschnitt betitelt „authors.resource.ts“import { computed, Injectable } from '@angular/core';import { httpResource } from '@angular/common/http';
import { Author } from '../entities/author.model';import { mapAuthorsResponseToDomain } from './author.mapper';
@Injectable()export class AuthorsResource { private readonly resource = httpResource<readonly Author[]>( () => '/api/authors', { parse: mapAuthorsResponseToDomain, }, );
readonly authors = computed<readonly Author[] | null>(() => { if (!this.resource.hasValue()) { return null; }
return this.resource.value(); });
readonly isLoading = this.resource.isLoading; readonly error = this.resource.error;
reload(): void { this.resource.reload(); }}categories.resource.ts
Abschnitt betitelt „categories.resource.ts“import { computed, Injectable } from '@angular/core';import { httpResource } from '@angular/common/http';
import { Category } from '../entities/category.model';import { mapCategoriesResponseToDomain } from './category.mapper';
@Injectable()export class CategoriesResource { private readonly resource = httpResource<readonly Category[]>( () => '/api/categories', { parse: mapCategoriesResponseToDomain, }, );
readonly categories = computed<readonly Category[] | null>(() => { if (!this.resource.hasValue()) { return null; }
return this.resource.value(); });
readonly isLoading = this.resource.isLoading; readonly error = this.resource.error;
reload(): void { this.resource.reload(); }}Die drei Adapter behandeln dieselbe technische Frage:
Kann die konkrete Angular Resource sicher gelesen werden?Dafür bleibt hasValue() genau dort, wo die Resource erzeugt wird.
Der Store kennt danach nur noch:
ArticlesResource.articlesAuthorsResource.authorsCategoriesResource.categoriesDie eigentliche Aggregation passiert nicht in der Infrastructure.
Sie soll nicht wissen, welches ViewModel später gebaut wird.
Sie kennt nur ihre jeweilige Quelle.
ArticlesResource → Artikel laden → technischen Lifecycle kapseln → articles-Signal bereitstellen
AuthorsResource → Autoren laden → technischen Lifecycle kapseln → authors-Signal bereitstellen
CategoriesResource → Kategorien laden → technischen Lifecycle kapseln → categories-Signal bereitstellenNicht:
ArticlesResource → Artikel laden → Autoren laden → Kategorien laden → UI-Modell bauenDas wäre wieder ein versteckter Page-Service.
Die Infrastructure entscheidet nur, ob eine technische Resource sicher gelesen werden kann.
Welche verfügbaren Sources für einen gültigen UI-Vertrag ausreichen, entscheidet die Projektion.
ViewModel
Abschnitt betitelt „ViewModel“Für die Übersicht reicht ein kleines ViewModel.
export interface ArticleOverviewVm { readonly items: readonly ArticleOverviewItemVm[];}
export interface ArticleOverviewItemVm { readonly id: string; readonly title: string; readonly authorName: string | null; readonly categoryLabel: string | null;}Auffällig ist hier:
readonly authorName: string | null;readonly categoryLabel: string | null;Das ist Absicht.
null bedeutet hier nicht automatisch „Fehler“.
null bedeutet:
Diese Teilinformation ist aktuell nicht verfügbar.Das wird im zweiten Case wichtig.
Denn ein ViewModel muss nicht immer binär sein:
fertigodernicht fertigEs kann auch ein stabiler UI-Vertrag sein, der fehlende Teilinformationen bewusst ausdrückt.
Mapper und Projektionsregeln
Abschnitt betitelt „Mapper und Projektionsregeln“Die Aggregation selbst bleibt pure.
Der Store sammelt die Source Signals.
Benannte Projektionsfunktionen entscheiden, welche Quellen für den jeweiligen UI-Vertrag erforderlich sind.
Der eigentliche Builder baut aus den verfügbaren Daten das ViewModel.
import { Article } from '../entities/article.model';import { Author } from '../entities/author.model';import { Category } from '../entities/category.model';import { ArticleOverviewItemVm, ArticleOverviewVm,} from './article-overview.vm';
interface ArticleOverviewSources { readonly articles: readonly Article[] | null; readonly authors: readonly Author[] | null; readonly categories: readonly Category[] | null;}
interface AvailableArticleOverviewSources { readonly articles: readonly Article[]; readonly authors: readonly Author[] | null; readonly categories: readonly Category[] | null;}
export const toCompleteArticleOverviewViewModel = ({ articles, authors, categories,}: ArticleOverviewSources): ArticleOverviewVm | null => { if ( articles === null || authors === null || categories === null ) { return null; }
return buildArticleOverviewViewModel({ articles, authors, categories, });};
export const toProgressiveArticleOverviewViewModel = ({ articles, authors, categories,}: ArticleOverviewSources): ArticleOverviewVm | null => { if (articles === null) { return null; }
return buildArticleOverviewViewModel({ articles, authors, categories, });};
const buildArticleOverviewViewModel = ({ articles, authors, categories,}: AvailableArticleOverviewSources): ArticleOverviewVm => { const authorsById = authors ? new Map(authors.map((author) => [author.id, author])) : null;
const categoriesById = categories ? new Map(categories.map((category) => [category.id, category])) : null;
return { items: articles.map( (article): ArticleOverviewItemVm => ({ id: article.id, title: article.title, authorName: authorsById?.get(article.authorId)?.name ?? null, categoryLabel: categoriesById?.get(article.categoryId)?.label ?? null, }), ), };};Die beiden öffentlichen Funktionen beantworten unterschiedliche Fragen:
toCompleteArticleOverviewViewModel → Sind alle Sources vorhanden?
toProgressiveArticleOverviewViewModel → Ist die führende Source vorhanden?Der gemeinsame Builder beantwortet nur:
Wie entsteht aus den verfügbaren Daten das ViewModel?Damit bleiben drei Verantwortungen getrennt:
Infrastructure → technische Resource absichern
Projektionsfunktion → erforderliche Sources bestimmen
Builder → vorhandene Sources in ViewModel übersetzenDer Store muss keine Conditions mehr enthalten.
Er wählt nur eine benannte Projektion und verbindet sie mit den Source Signals.
Case 1: Projektions-Guard — alle Quellen sind erforderlich
Abschnitt betitelt „Case 1: Projektions-Guard — alle Quellen sind erforderlich“Manchmal ergibt die Seite nur Sinn, wenn alle Daten da sind.
Dann ist ein vollständiger Projektions-Guard sinnvoll:
articles fehlt → kein VM
authors fehlt → kein VM
categories fehlt → kein VM
alles da → VM bauenDas ist die moderne Signal-Variante von:
warte auf alle → baue ErgebnisNur dass wir nicht Requests aggregieren.
Wir aggregieren Resource-Zustand.

import { computed, inject } from '@angular/core';import { signalStore, withComputed, withProps } from '@ngrx/signals';
import { ArticlesResource } from '../infrastructure/articles.resource';import { AuthorsResource } from '../infrastructure/authors.resource';import { CategoriesResource } from '../infrastructure/categories.resource';import { toCompleteArticleOverviewViewModel } from './article-overview.mapper';
export const ArticleOverviewStore = signalStore( withProps(() => ({ _articlesResource: inject(ArticlesResource), _authorsResource: inject(AuthorsResource), _categoriesResource: inject(CategoriesResource), })),
withComputed( ({ _articlesResource, _authorsResource, _categoriesResource, }) => ({ vm: computed(() => toCompleteArticleOverviewViewModel({ articles: _articlesResource.articles(), authors: _authorsResource.authors(), categories: _categoriesResource.categories(), }), ), }), ),);Das ViewModel ist hier erst vorhanden, wenn alle drei Source Signals Werte liefern.
Der Store selbst enthält keine sichtbare Guard-Logik.
Die Entscheidung steckt in der benannten Projektion:
articles+ authors+ categories → toCompleteArticleOverviewViewModel() → ArticleOverviewVm | nullDie UI kann in dieser Zeit einen einfachen Platzhalter zeigen:
@let vm = facade.vm();
@if (vm) { @for (item of vm.items; track item.id) { <article> <h2>{{ item.title }}</h2> <p>{{ item.authorName }}</p> <p>{{ item.categoryLabel }}</p> </article> }} @else { <app-overview-placeholder />}Das ist einfach.
Und es ist korrekt, wenn Teilinformationen fachlich keinen Wert haben.
Was passiert bei computed?
Abschnitt betitelt „Was passiert bei computed?“computed merkt sich, welche Signale während der Berechnung gelesen wurden.
In unserem Fall:
_articlesResource.articles()_authorsResource.authors()_categoriesResource.categories()Die technische Resource-API ist dabei bereits verschwunden.
computed beobachtet nur die anwendungsnahen Source Signals.
Sobald sich eine der gelesenen Abhängigkeiten ändert, wird die Ableitung ungültig.
Beim nächsten Lesen von vm() wird sie neu berechnet.
Das ist der entscheidende Punkt.
Nicht der Store pusht manuell ein neues VM.
Nicht die Komponente ruft rebuildVm().
Nicht ein Service merged drei Subscriptions.
Sondern:
Source Signal ändert sich → computed wird ungültig → vm() wird erneut gelesen → neue Ableitung entsteht → Template rendert neuen StandDas ist der mentale Wechsel.
Case 2: Projektions-Guard — nur die Hauptquelle ist erforderlich
Abschnitt betitelt „Case 2: Projektions-Guard — nur die Hauptquelle ist erforderlich“Jetzt wird es interessanter.
Vielleicht ist articles die Hauptquelle.
Ohne Artikel kann die Seite nichts anzeigen.
Aber Autoren und Kategorien sind nur Anreicherungen.
Dann muss die UI nicht auf alles warten.
articles fehlt → kein VM
articles da → VM bauen
authors fehlen → authorName: null
categories fehlen → categoryLabel: null
authors kommen später → VM aktualisiert sich
categories kommen später → VM aktualisiert sichDas ist kein Hack.
Das ist ein bewusst partielles ViewModel.
import { computed, inject } from '@angular/core';import { signalStore, withComputed, withProps } from '@ngrx/signals';
import { ArticlesResource } from '../infrastructure/articles.resource';import { AuthorsResource } from '../infrastructure/authors.resource';import { CategoriesResource } from '../infrastructure/categories.resource';import { toProgressiveArticleOverviewViewModel } from './article-overview.mapper';
export const ArticleOverviewStore = signalStore( withProps(() => ({ _articlesResource: inject(ArticlesResource), _authorsResource: inject(AuthorsResource), _categoriesResource: inject(CategoriesResource), })),
withComputed( ({ _articlesResource, _authorsResource, _categoriesResource, }) => ({ vm: computed(() => toProgressiveArticleOverviewViewModel({ articles: _articlesResource.articles(), authors: _authorsResource.authors(), categories: _categoriesResource.categories(), }), ), }), ),);Der Store-Code unterscheidet sich nur durch die gewählte Projektion.
Aber die Wirkung ist groß.
Im ersten Case sagen wir:
Ich baue das VM erst,wenn alle Quellen da sind.Im zweiten Case sagen wir:
Ich brauche die Hauptquelle.Alles andere ist progressive Anreicherung.
Sobald authorsResource später einen Wert bekommt, wird vm neu berechnet.
Dann werden aus:
authorName: nullautomatisch:
authorName: "Ada Lovelace"Ohne manuelles Nachpatchen.
Ohne zweite Subscription.
Ohne combineLatest in der Komponente.
Ohne imperative Synchronisation.
Warum das funktioniert
Abschnitt betitelt „Warum das funktioniert“Das computed im zweiten Case liest nicht nur articles.
Es liest auch die Source Signals der Nebenquellen:
_articlesResource.articles()_authorsResource.authors()_categoriesResource.categories()Damit werden alle drei Signals zu Abhängigkeiten der Ableitung.
Wenn AuthorsResource.authors() später von null auf einen Wert wechselt, wird das computed ungültig.
Beim nächsten Lesen entsteht ein neues ViewModel.
AuthorsResource.authors() null → Author[]
computed invalidiert → vm() wird neu gelesen → progressive Projektion wird erneut ausgeführt → authorName wird befülltDas ist der zentrale Punkt.
Die UI muss nicht wissen, welche Source gerade einen Wert geliefert hat.
Sie liest nur wieder vm().
Und vm() ist eine neue Ableitung des aktuellen Zustands.

Template für partielle Daten
Abschnitt betitelt „Template für partielle Daten“Das Template kann mit null bewusst umgehen.
@let vm = facade.vm();
@if (vm) { @for (item of vm.items; track item.id) { <article> <h2>{{ item.title }}</h2>
@if (item.authorName) { <p>{{ item.authorName }}</p> } @else { <p>Autor noch nicht verfügbar</p> }
@if (item.categoryLabel) { <p>{{ item.categoryLabel }}</p> } @else { <p>Kategorie noch nicht verfügbar</p> } </article> }} @else { <app-overview-placeholder />}Die Komponente entscheidet nicht, welche APIs fehlen.
Sie rendert nur den Vertrag:
vm === null → Hauptdaten fehlen
authorName === null → Autor nicht verfügbar
categoryLabel === null → Kategorie nicht verfügbarDamit bleibt die Komponente flach.
Warum nicht einfach überall []?
Abschnitt betitelt „Warum nicht einfach überall []?“Eine kleine, aber wichtige Falle:
const authors = _authorsResource.authors() ?? [];Das sieht bequem aus.
Aber es verwischt zwei Zustände.
[]kann bedeuten:
noch nicht geladenoder:
geladen, aber leerDas sind unterschiedliche Aussagen.
Deshalb ist bei abhängigen Quellen oft besser:
const authors = _authorsResource.authors();Dann ist der Vertrag klar:
null → Quelle ist noch nicht verfügbar
[] → Quelle ist verfügbar, enthält aber keine EinträgeDas macht die UI ehrlicher.
Und die Tests besser.
Die Unterscheidung bleibt dabei Teil des Source-Vertrags:
AuthorsResource.authors() → null | readonly Author[]Der Store muss nicht wissen, wie die Infrastructure diesen Vertrag aus httpResource ableitet.
forkJoin ist nicht tot
Abschnitt betitelt „forkJoin ist nicht tot“Dieser Artikel ist kein Plädoyer gegen forkJoin.
forkJoin ist weiterhin sinnvoll, wenn der fachliche Prozess genau das meint:
Starte mehrere einmalige Operationen.Warte auf alle Ergebnisse.Fahre danach fort.Zum Beispiel:
- Export vorbereiten
- Dialog erst öffnen, wenn alle Stammdaten geladen sind
- Wizard initial vollständig befüllen
- einmalige Berechnung starten
- mehrere Commands abschließen und danach weitergehen
Aber für Page-ViewModels ist forkJoin oft nicht die beste Denke.
Ein Page-ViewModel ist selten nur ein einmaliges Ergebnis.
Es ist meist eine reaktive Projektion.
Source Signal ASource Signal BSource Signal C → computed → ViewModelWenn sich eine Quelle ändert, soll das ViewModel nicht neu orchestriert werden.
Es soll sich neu ableiten.
Ausblick: partielle Skeletons
Abschnitt betitelt „Ausblick: partielle Skeletons“In diesem Artikel habe ich Loading bewusst nicht in das ViewModel eingebaut.
Das wäre ein nächster Schritt.
Aus dem gleichen Muster kann man später sehr präzise Loading-Zustände ableiten:
authorName fehltund AuthorsResource.isLoading() ist true → lokaler Author-Skeleton
categoryLabel fehltund CategoriesResource.isLoading() ist true → lokaler Category-SkeletonDann wäre Loading kein globaler Schalter für die ganze Seite mehr.
Sondern Teil des ViewModel-Vertrags.
Die UI könnte Artikel bereits anzeigen und nur die Bereiche mit Skeletons markieren, deren Nebenquellen noch fehlen.
Das ist eine starke Erweiterung.
Aber es ist nicht notwendig für das Grundpattern.
Das Grundpattern ist:
technische Resources in der Infrastructure kapselnSource Signals lesenProjektions-Guards anwendenfehlende Nebendaten bewusst als null modellierenViewModel reaktiv ableitenDer eigentliche Punkt
Abschnitt betitelt „Der eigentliche Punkt“Der eigentliche Punkt ist nicht, ob man drei HTTP-Calls mit forkJoin oder drei Resources mit computed schreibt.
Der eigentliche Punkt ist:
Sehe ich mein ViewModel als Ergebnis eines Requests? Oder als Ableitung aus reaktivem Zustand?
Das ist ein fundamentaler Unterschied.
Bei forkJoin denke ich in Abschluss:
alle Requests fertig → Ergebnis bauenBei computed denke ich in Gültigkeit:
welche Source Signals liefern gerade Werte? → daraus gültiges VM ableitenDadurch entstehen neue Möglichkeiten.
Nicht nur:
alles ladendann alles anzeigensondern auch:
Hauptdaten anzeigenNebendaten ergänzenfehlende Teile explizit modellierenUI-Vertrag stabil haltenDas ist der Punkt, an dem Frontend-Architektur Spaß macht.
Nicht, weil der Code komplizierter wird.
Sondern weil der Code mehr ausdrücken kann.
Ein ViewModel aus mehreren Datenquellen ist kein Sonderfall.
Es ist ein normaler Frontend-Schnitt.
Die Frage ist nur, wie bewusst wir ihn modellieren.
Case 1:Alle Quellen sind erforderlich. → vollständige Projektion → VM erst bauen, wenn alle Source Signals Werte liefern
Case 2:Eine Quelle ist führend.Andere Quellen reichern an. → progressive Projektion → nur Hauptquelle ist erforderlich → fehlende Teile als null modellieren → VM aktualisiert sich, sobald Nebendaten eintreffencomputed ist dafür der entscheidende Baustein.
Es liest benannte Source Signals.
Es merkt sich die gelesenen Abhängigkeiten.
Es wird ungültig, wenn sich eine dieser Abhängigkeit ändert.
Und es erzeugt beim nächsten Lesen eine neue Ableitung.
Source Signal ändert sich → computed invalidiert → ViewModel wird neu abgeleitet → UI rendert neuen StandDas ist der Unterschied zu früher.
Nicht, weil früher alles falsch war.
Sondern weil wir heute mehr Kontrolle über den UI-Vertrag haben.
Die zentrale Grenze bleibt dabei klar:
Infrastructure → entscheidet, ob eine technische Resource sicher gelesen werden kann
ViewModel-Projektion → entscheidet, welche verfügbaren Sources für einen gültigen UI-Vertrag ausreichenUnd wenn man das sauber macht, entstehen plötzlich Möglichkeiten:
partielle ViewModelsflache Komponententestbare Mappersichtbare Ableitungenspäter auch lokale SkeletonsGenau da liegt der Wert.
Nicht in mehr Framework-Magie.
Sondern in klarerer Modellierung.