Zum Inhalt springen

ViewModel aus mehreren Datenquellen

Ein ViewModel entsteht nicht immer aus genau einer API-Antwort.

Oft sieht die Realität eher so aus:

articles
authors
categories
→ ArticleOverviewVm

Oder allgemeiner:

Hauptdaten
Referenzdaten
Zusatzdaten
→ UI-Projektion

Frü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 bauen

sondern:

Datenquellen als reaktive Inputs behandeln
→ ViewModel als Ableitung berechnen

Nicht Requests werden verheiratet. Zustand wird abgeleitet.

Das klingt nach einem kleinen Unterschied.

In der Praxis verändert es aber die Architektur.


Dieser Artikel baut auf dem Retrieve-Slice auf.

Das Drumherum bleibt identisch:

Infrastructure kapselt den Resource-Lifecycle
Infrastructure stellt sichere Source Signals bereit
Store orchestriert Sources und Projektion
Facade exponiert ViewModel
Component rendert

Der 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
→ mapToDomain

Der 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 ausreichen

Diese 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.


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, label

Das UI möchte aber kein technisches Datenmodell.

Das UI möchte ein ViewModel:

ArticleOverviewVm
→ title
→ authorName
→ categoryLabel

Die Zuordnung entsteht also aus drei Quellen:

article.authorId
→ authors.find(author.id)
article.categoryId
→ categories.find(category.id)

Die Infrastruktur lädt Quellen. Das ViewModel aggregiert sie als Ableitung.


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.

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();
}
}
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();
}
}
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.articles
AuthorsResource.authors
CategoriesResource.categories

Die 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 bereitstellen

Nicht:

ArticlesResource
→ Artikel laden
→ Autoren laden
→ Kategorien laden
→ UI-Modell bauen

Das 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.


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:

fertig
oder
nicht fertig

Es kann auch ein stabiler UI-Vertrag sein, der fehlende Teilinformationen bewusst ausdrückt.


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 übersetzen

Der 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 bauen

Das ist die moderne Signal-Variante von:

warte auf alle
→ baue Ergebnis

Nur dass wir nicht Requests aggregieren.

Wir aggregieren Resource-Zustand.

Wenn jede Quelle fachlich erforderlich ist, schützt der Guard das 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 { 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 | null

Die 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.


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 Stand

Das 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 sich

Das 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.

Die Hauptdaten tragen die Seite. Nebendaten reichern das ViewModel später an.

Sobald authorsResource später einen Wert bekommt, wird vm neu berechnet.

Dann werden aus:

authorName: null

automatisch:

authorName: "Ada Lovelace"

Ohne manuelles Nachpatchen.

Ohne zweite Subscription.

Ohne combineLatest in der Komponente.

Ohne imperative Synchronisation.


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üllt

Das 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.

Jede eintreffende Quelle invalidiert computed. Das ViewModel wird neu abgeleitet.


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ügbar

Damit bleibt die Komponente flach.


Eine kleine, aber wichtige Falle:

const authors = _authorsResource.authors() ?? [];

Das sieht bequem aus.

Aber es verwischt zwei Zustände.

[]

kann bedeuten:

noch nicht geladen

oder:

geladen, aber leer

Das 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äge

Das 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.


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 A
Source Signal B
Source Signal C
→ computed
→ ViewModel

Wenn sich eine Quelle ändert, soll das ViewModel nicht neu orchestriert werden.

Es soll sich neu ableiten.


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 fehlt
und AuthorsResource.isLoading() ist true
→ lokaler Author-Skeleton
categoryLabel fehlt
und CategoriesResource.isLoading() ist true
→ lokaler Category-Skeleton

Dann 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 kapseln
Source Signals lesen
Projektions-Guards anwenden
fehlende Nebendaten bewusst als null modellieren
ViewModel reaktiv ableiten

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 bauen

Bei computed denke ich in Gültigkeit:

welche Source Signals liefern gerade Werte?
→ daraus gültiges VM ableiten

Dadurch entstehen neue Möglichkeiten.

Nicht nur:

alles laden
dann alles anzeigen

sondern auch:

Hauptdaten anzeigen
Nebendaten ergänzen
fehlende Teile explizit modellieren
UI-Vertrag stabil halten

Das 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 eintreffen

computed 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 Stand

Das 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 ausreichen

Und wenn man das sauber macht, entstehen plötzlich Möglichkeiten:

partielle ViewModels
flache Komponenten
testbare Mapper
sichtbare Ableitungen
später auch lokale Skeletons

Genau da liegt der Wert.

Nicht in mehr Framework-Magie.

Sondern in klarerer Modellierung.