Zum Inhalt springen

Wenn der Read Store zu groß wird

Warum Source State und View-Projektionen nicht immer im selben Store bleiben sollten

Ein Read Store ist eine gute Idee.

Die Infrastructure lädt Daten und übersetzt das DTO.
Ein Infrastructure-Adapter kapselt die konkrete httpResource.
Der Store leitet mit withComputed ein ViewModel aus benannten Source Signals ab.
Die View rendert.

private httpResource
→ Infrastructure-Adapter
→ sichere Source Signals
→ Read Store
→ withComputed
→ ViewModel
→ View

Für einen kleinen Use Case ist das häufig genau der richtige Schnitt.

Eine View.
Eine Quelle.
Ein gemeinsamer Lebenszyklus.
Ein überschaubares ViewModel.

Mehr braucht es nicht.

Die technische Angular Resource bleibt dabei in der Infrastructure. Dort werden hasValue(), der sichere Zugriff auf value(), Loading, Error und Reload gekapselt.

Der Store kennt nur die Signale, die er für seine Projektion braucht.

Das ist keine zusätzliche Zeremonie. Es verbessert den Lesefluss:

article
→ ViewModel
isLoading
→ Page State
reload intent
→ ArticleResource.reload()

Im Store interessiert die Orchestrierung.

Nicht die Gebrauchsanweisung von httpResource.

Das ist nicht die naive Vorstufe einer später „richtigen“ Architektur.

Das ist bereits eine gute Architektur.

Der Retrieve-Slice zeigt genau diesen Ausgangspunkt: Die Infrastructure kapselt den technischen Resource-Lifecycle, stellt sichere Source Signals bereit, und der Store projiziert daraus den Zustand für die Presentation.

Solange Datenquelle, View-State und Lebenszyklus zusammengehören, gibt es keinen guten Grund, daraus vorsorglich mehrere Stores zu machen.

Drei Stores sind nicht automatisch mehr Architektur.

Manchmal sind sie nur drei Stellen, an denen man nachsehen muss.

Solange Datenquelle, View-State und Lebenszyklus zusammengehören, ist ein Store häufig die beste Architektur.

Solange Datenquelle, View-State und Lebenszyklus zusammengehören, ist ein Store häufig die beste Architektur.


Die Anwendung bleibt nur selten so klein wie ihr erster Slice.

Aus der Artikelübersicht wird ein Content-System.

Neben der Overview entstehen weitere Views:

Overview
Archive
Workspace
Preview
Reader

Alle arbeiten mit Artikeln.

Damit liegt eine scheinbar einfache Entscheidung nahe:

Wir haben bereits einen Article Store. Die neuen Zustände kommen dort ebenfalls hinein.

Zuerst kommt die Auswahl des aktiven Artikels hinzu.

Dann die aufgeklappten Einträge im Archiv.

Dann ein Dialog-Target für das Wiederherstellen.

Dann der aktive Workspace-Kontext.

Dann Preview-Locale, Reader-Index und ein paar Filter.

Irgendwann enthält derselbe Store:

  • Catalog Resource
  • Workspace Resource
  • Image Resource
  • Overview Selection
  • Archive Expansion
  • Workspace Dialog Targets
  • Filter- und Sortierintents
  • Preview State
  • Reader Index
  • Read Loading
  • sichtbaren Write-State
  • mehrere Page-ViewModels

Der Store kann dabei weiterhin funktionieren.

Die Signale aktualisieren sich.
Die Views zeigen Daten.
Die Tests sind grün.

Es gibt keinen einzelnen Commit, in dem die Architektur plötzlich umkippt.

Der Store war für seinen ursprünglichen Use Case richtig. Die Anwendung hat nur schrittweise mehr Verantwortung in dieselbe Ownership-Grenze gelegt.

Das ist ein wichtiger Unterschied.

Wer den ursprünglichen Store im Rückblick zum Fehler erklärt, lernt wenig daraus. Die interessantere Frage lautet:

Ab welchem Punkt besitzt dieser Store nicht mehr einen zusammenhängenden Read-Slice, sondern mehrere Zustände mit unterschiedlichen Lebenszyklen und Bedeutungen?

Der Store wird nicht durch viele Zeilen problematisch, sondern durch viele Lebenszyklen, Consumer und Bedeutungen.

Der Store wird nicht durch viele Zeilen problematisch, sondern durch viele Lebenszyklen und Bedeutungen.


Ein Store mit 2.000 Zeilen ist verdächtig.

Aber die Zeilenzahl ist keine Architekturregel.

Ein großer Store kann einen komplexen, aber kohärenten Use Case besitzen. Seine Zustände werden gemeinsam aktiviert, gemeinsam verworfen und meist gemeinsam geändert.

Ein Store mit 250 Zeilen kann dagegen bereits drei verschiedene Owner enthalten.

Zum Beispiel:

Catalog Reload
→ betrifft Overview und Archive
Workspace Selection
→ betrifft nur den Editor
Reader Index
→ betrifft nur die aktuelle Lesesitzung

Diese Zustände teilen weder denselben Lebenszyklus noch dieselben Consumer.

Sie liegen nur im selben Store, weil sie technisch mit Article zu tun haben.

Das ist eine schwache Begründung.

Gemeinsamer Datentyp bedeutet nicht automatisch gemeinsame Ownership.

Auch eine gemeinsame Darstellung rechtfertigt noch keinen gemeinsamen State Owner. Overview und Archive können dieselbe Article Entity lesen und trotzdem völlig unterschiedliche Entscheidungen treffen.

Die bessere Diagnose lautet deshalb nicht:

Der Store ist zu lang.

Sondern:

Zustände mit unterschiedlichen Lebenszyklen, Consumern, Bedeutungen und Änderungsgründen teilen sich denselben Owner.

Die Zeilenzahl ist ein Hinweis.

Ownership ist die Begründung.


Bevor wir Store-Grenzen verschieben, muss eine andere Grenze klar bleiben.

HTTP DTO
→ Infrastructure Mapper
→ Domain Entity
→ gekapselte Source Signals
→ optionaler Source Store
→ View Read Store
→ ViewModel
→ Presentation

Diese Modelltrennung hängt nicht von der Größe des Use Cases ab.

Sie gilt auch dann, wenn ein einziger Store vollkommen ausreicht.

Eine externe API darf technisch geprägt sein.

export type ArticleDto = {
readonly article_id: string;
readonly slug?: string | null;
readonly headline: string | null;
readonly teaser_text?: string | null;
readonly publication_status?: 'DRAFT' | 'LIVE' | 'ARCHIVED' | null;
readonly hero_image?: {
readonly url?: string | null;
} | null;
readonly author?: {
readonly user_id?: string | null;
readonly display_name?: string | null;
} | null;
readonly tags?: readonly (string | null)[] | null;
readonly published_at?: string | null;
readonly archived_at?: string | null;
readonly updated_at: string;
};

Optionale Felder, null, Backend-Bezeichnungen und technische Verschachtelungen sind hier kein Architekturfehler.

Das DTO beschreibt einen externen Vertrag.

Es darf nur nicht ungebremst zum internen Modell des Frontends werden.

export type ArticleStatus = 'draft' | 'published' | 'archived';
export type ArticleAuthor = {
readonly id: string;
readonly name: string;
};
export type Article = {
readonly id: string;
readonly slug: string;
readonly title: string;
readonly summary: string;
readonly status: ArticleStatus;
readonly heroImageUrl: string | null;
readonly author: ArticleAuthor | null;
readonly tags: readonly string[];
readonly publishedAt: number | null;
readonly archivedAt: number | null;
readonly updatedAt: number;
};

Diese Entity ist transportbereinigt.

Sie verwendet die Sprache des Frontend-SCS. Sie ist nicht auf eine einzelne View zugeschnitten und kann von Overview, Archive, Workspace und Preview gemeinsam verwendet werden.

Dafür braucht sie keine mutable OOP-Klasse.

Sie braucht auch kein ArticleInterface, keine ArticleImpl und keinen abstrakten BaseContentEntityManager.

Ein readonly TypeScript-Typ reicht, wenn er das fachliche Modell sauber ausdrückt.

Die Infrastructure übersetzt von außen nach innen

Abschnitt betitelt „Die Infrastructure übersetzt von außen nach innen“
export const mapArticleDto = (dto: ArticleDto): Article => {
const title = dto.headline?.trim();
if (!title) {
throw new Error(`Article ${dto.article_id} has no title.`);
}
return {
id: dto.article_id,
slug: dto.slug?.trim() || dto.article_id,
title,
summary: dto.teaser_text?.trim() ?? '',
status: mapArticleStatus(dto.publication_status),
heroImageUrl: dto.hero_image?.url?.trim() || null,
author: mapArticleAuthor(dto.author),
tags: (dto.tags ?? []).filter((tag): tag is string => typeof tag === 'string' && tag.trim().length > 0).map((tag) => tag.trim()),
publishedAt: mapTimestamp(dto.published_at),
archivedAt: mapTimestamp(dto.archived_at),
updatedAt: mapRequiredTimestamp(dto.updated_at, dto.article_id),
};
};

Nach diesem Mapper ist das Transportmodell beendet.

Der Rest des Frontends arbeitet mit Article.

Der Anti-Corruption-Layer-Artikel behandelt ausführlicher, warum diese Grenze wichtig ist. Für den weiteren Schnitt interessiert vor allem:

DTO und Entity bleiben getrennt, egal ob wir später einen oder fünf Stores verwenden.

Die API beschreibt den Transport. Die Entity beschreibt das Frontend-Fachmodell. Das ViewModel beschreibt den konkreten Use Case.

Die API beschreibt den Transport. Die Entity beschreibt das Frontend-Fachmodell. Das ViewModel beschreibt den konkreten Use Case.


Eine Entity beschreibt, was ein Artikel innerhalb des Frontend-Bounded-Contexts ist.

Ein ViewModel beschreibt, was dieser Artikel für eine konkrete Oberfläche bedeutet.

Das ist nicht dasselbe.

Ein Artikel mit:

status: 'archived';

kann in mehreren Views unterschiedlich interpretiert werden.

Overview
→ nicht anzeigen
Archive
→ Wiederherstellen anbieten
Workspace
→ Bearbeitung deaktivieren
Preview
→ keine veröffentlichbare Darstellung erzeugen

Die Entity muss dafür nicht vier verschiedene Statusfelder erhalten.

Sie stellt die fachliche Information bereit.

Die Views projizieren daraus ihre Bedeutung.

Article
├→ ArticleOverviewItemVm
├→ ArticleArchiveItemVm
├→ ArticleWorkspaceVm
└→ ArticlePreviewVm

Genau hier beginnt die Frage nach getrennten View Read Stores.

Nicht weil jede View zwingend einen Store verdient.

Sondern weil mehrere Views dieselben Entities zunehmend unterschiedlich interpretieren.

Der Grundlagenartikel zur ViewModel Aggregation erklärt, warum ein ViewModel kein durchgereichtes Backend-Modell ist. Hier geht es um den nächsten Schritt:

Wer besitzt die unterschiedlichen Projektionen, wenn dieselbe Datenquelle mehrere Views bedient?


Der Source Store besitzt den gemeinsamen Source-Kontext

Abschnitt betitelt „Der Source Store besitzt den gemeinsamen Source-Kontext“

Der Source Store beantwortet eine begrenzte Frage:

Welche fachlichen Daten stehen für mehrere Consumer gemeinsam zur Verfügung?

Er besitzt aber nicht automatisch die konkrete Angular Resource.

Das ist die neue, wichtige Grenze:

Infrastructure-Adapter
→ besitzt private HttpResourceRef
→ kapselt hasValue(), value(), Loading, Error und Reload
→ stellt sichere Source Signals bereit
Source Store
→ besitzt gemeinsamen Source-Kontext
→ besitzt Query-Identität und Invalidierungsregeln
→ stellt Entities und Read-State für mehrere Consumer bereit

Der technische Resource-Lifecycle bleibt in der Infrastructure.

Der Source Store besitzt die Bedeutung dieser Quelle innerhalb des Frontend-SCS.

Die Infrastructure stellt zunächst eine gekapselte Source bereit:

@Injectable({ providedIn: 'root' })
export class ArticleCatalogResource {
private readonly resource = httpResource<readonly Article[]>(
() => ({
url: '/api/content/articles',
method: 'GET',
}),
{
parse: mapArticleResponse,
},
);
readonly articles = computed<readonly Article[] | null>(() => {
if (!this.resource.hasValue()) {
return null;
}
return this.resource.value();
});
readonly isLoading = this.resource.isLoading;
readonly error = computed(() => this.resource.error() ?? null);
reload(): void {
this.resource.reload();
}
}

Diese Klasse kapselt die technische Angular-API vollständig.

Nach außen existiert nicht mehr:

HttpResourceRef<Article[]>

Sondern:

articles: Signal<readonly Article[] | null>
isLoading: Signal<boolean>
error: Signal<unknown | null>
reload(): void

hasValue() ist hier richtig.

Es schützt den Zugriff auf value().

Das ist kein fachlicher Guard und keine ViewModel-Entscheidung. Es ist technische Lifecycle-Semantik der Resource.

Genau deshalb verlässt dieser Guard die Infrastructure nicht.

Wenn mehrere Views dieselbe Quelle, Query und Invalidierung teilen, kann darüber ein Source Store liegen:

type ArticlePageReadState = 'idle' | 'loading' | 'refreshing' | 'ready' | 'error';
export const ArticleCatalogSourceStore = signalStore(
{ providedIn: 'root' },
withProps(() => {
const catalogResource = inject(ArticleCatalogResource);
return {
_catalogResource: catalogResource,
articles: catalogResource.articles,
isLoading: catalogResource.isLoading,
readError: catalogResource.error,
};
}),
withComputed(({ articles, isLoading, readError }) => ({
readState: computed<ArticlePageReadState>(() =>
toArticleReadState({
hasData: articles() !== null,
isLoading: isLoading(),
error: readError(),
}),
),
})),
withMethods(({ _catalogResource }) => ({
reload: (): void => {
_catalogResource.reload();
},
})),
);

Der gemeinsame Lifecycle-Mapper arbeitet bereits auf der sicheren Source-API:

const toArticleReadState = ({ hasData, isLoading, error }: { readonly hasData: boolean; readonly isLoading: boolean; readonly error: unknown | null }): ArticlePageReadState => {
if (!hasData && error) {
return 'error';
}
if (!hasData && isLoading) {
return 'loading';
}
if (!hasData) {
return 'idle';
}
return isLoading ? 'refreshing' : 'ready';
};

Auffällig ist, was weder Adapter noch Source Store machen.

Sie kopieren den Resource-Wert nicht in ein eigenes withState.

Sie halten kein zweites articlesLoading.

Sie setzen keinen Fehler manuell zurück, wenn die Source neu lädt.

Sie bauen keine zweite technische State Machine neben httpResource.

private httpResource
→ sicherer Source-Vertrag
→ gemeinsamer Read-State

Der Source Store benennt und teilt diesen Vertrag.

Er interpretiert nicht noch einmal die Angular Resource.

Er besitzt außerdem keine Archive Expansion, keinen Reader Index und kein Preview-Dialog-Target.

Das sind keine Eigenschaften der gemeinsamen Quelle.

Das sind Eigenschaften der Art, wie eine konkrete View diese Daten betrachtet.

Für einen kleinen Use Case ohne geteilte Source-Ownership ist dieser zusätzliche Source Store möglicherweise unnötig.

Dann kann der View Read Store direkt die gekapselte ArticleCatalogResource konsumieren.

Der Source Store verdient seine Existenz erst, wenn mehrere Consumer tatsächlich einen gemeinsamen Source-Kontext teilen.

Der View Read Store beantwortet eine andere Frage:

Was bedeuten diese Daten für genau diese View?

Dafür liest er:

  • Domain Entities aus einem Source Store oder direkt aus einem gekapselten Infrastructure-Adapter
  • einen bereits normalisierten Read-State
  • seinen eigenen View-State
  • optional eine konkrete readonly Write-State-Projektion

Er besitzt beispielsweise:

  • Selection
  • Expansion
  • Filter- und Sortierintents
  • aktive Tabs
  • Dialog Targets
  • Reader Index
  • Viewport State
  • use-case-spezifische Ableitungen

Aus diesen Eingängen erzeugt er mit withComputed ein vollständiges Page-ViewModel.

Die eigentlichen Fallentscheidungen bleiben dabei in benannten, puren Projektionsfunktionen.

Der Store soll beim Lesen nur zeigen:

Source
+ View-owned State
+ optionaler Write-State
→ Page ViewModel

Er besitzt jedoch nicht automatisch alle Eingangszustände.

Das ist eine wichtige Unterscheidung.

Infrastructure-Adapter
→ besitzt technische HttpResourceRef
→ stellt sichere Source Signals bereit
Source Store
→ besitzt gemeinsamen Source-Kontext
→ besitzt Query, Invalidierung und geteilten Read-State
View Read Store
→ besitzt View-State
→ orchestriert die Projektion
Write-State-Projektion
→ besitzt sichtbaren Write-Lifecycle

Der View Read Store ist die Projektionsstelle.

Er ist nicht das Endlager der gesamten Anwendung.

Overview und Archive verwenden dieselben Article Entities.

Ihre Mapper treffen aber unterschiedliche Entscheidungen.

export type ArticleOverviewItemVm = {
readonly id: string;
readonly title: string;
readonly summary: string;
readonly authorName: string | null;
readonly tags: readonly string[];
readonly updatedAt: number;
};
export const toArticleOverviewItemVm = (article: Article): ArticleOverviewItemVm | null => {
if (article.status !== 'published') {
return null;
}
return {
id: article.id,
title: article.title,
summary: article.summary,
authorName: article.author?.name ?? null,
tags: article.tags,
updatedAt: article.updatedAt,
};
};

Ein archivierter Artikel wird nicht mit isVisible: false an das Template weitergereicht.

Er gehört nicht in die Overview-Projektion.

export type ArticleArchiveItemVm = {
readonly id: string;
readonly title: string;
readonly archivedAt: number | null;
readonly canRestore: boolean;
};
export const toArticleArchiveItemVm = (article: Article): ArticleArchiveItemVm | null => {
if (article.status !== 'archived') {
return null;
}
return {
id: article.id,
title: article.title,
archivedAt: article.archivedAt,
canRestore: true,
};
};

Dieselbe Entity erhält jetzt eine andere UI-Bedeutung.

Nicht weil das Archive die Entity verändert.

Sondern weil es eine andere Frage stellt.

Die ViewModels enthalten dabei semantische Werte.

Datumsformatierung, Übersetzungen und sichtbare Labels bleiben in der Presentation.

Der Domain-Layer liefert keine deutschen Texte und keine Translation Keys.


Der Overview Store besitzt Filter, Sortierung und Selection.

Die Projektionsregel liegt in einer puren Funktion:

export type ArticleOverviewSort = 'updated' | 'title';
type ArticleOverviewState = {
readonly selectedTag: string | null;
readonly selectedArticleId: string | null;
readonly sortBy: ArticleOverviewSort;
};
export type ArticleOverviewPageVm = {
readonly readState: 'idle' | 'loading' | 'refreshing' | 'ready' | 'error';
readonly selectedTag: string | null;
readonly sortBy: ArticleOverviewSort;
readonly items: readonly (ArticleOverviewItemVm & {
readonly isSelected: boolean;
})[];
readonly isEmpty: boolean;
};
const toArticleOverviewPageViewModel = ({ articles, readState, selectedTag, selectedArticleId, sortBy }: { readonly articles: readonly Article[] | null; readonly readState: ArticleOverviewPageVm['readState']; readonly selectedTag: string | null; readonly selectedArticleId: string | null; readonly sortBy: ArticleOverviewSort }): ArticleOverviewPageVm => {
const items = (articles ?? [])
.filter((article) => selectedTag === null || article.tags.includes(selectedTag))
.flatMap((article) => {
const item = toArticleOverviewItemVm(article);
return item
? [
{
...item,
isSelected: item.id === selectedArticleId,
},
]
: [];
})
.toSorted((left, right) => (sortBy === 'title' ? left.title.localeCompare(right.title) : right.updatedAt - left.updatedAt));
return {
readState,
selectedTag,
sortBy,
items,
isEmpty: articles !== null && items.length === 0,
};
};

Der Store orchestriert nur noch die Eingänge:

export const ArticleOverviewReadStore = signalStore(
withState<ArticleOverviewState>({
selectedTag: null,
selectedArticleId: null,
sortBy: 'updated',
}),
withProps(() => ({
_catalog: inject(ArticleCatalogSourceStore),
})),
withComputed((store) => ({
vm: computed(() =>
toArticleOverviewPageViewModel({
articles: store._catalog.articles(),
readState: store._catalog.readState(),
selectedTag: store.selectedTag(),
selectedArticleId: store.selectedArticleId(),
sortBy: store.sortBy(),
}),
),
})),
withMethods((store) => ({
selectTag: (selectedTag: string | null): void => {
patchState(store, { selectedTag });
},
selectArticle: (selectedArticleId: string | null): void => {
patchState(store, { selectedArticleId });
},
changeSorting: (sortBy: ArticleOverviewSort): void => {
patchState(store, { sortBy });
},
})),
);

Das Archive besitzt andere Zustände und eine andere Projektion.

type ArticleArchiveState = {
readonly expandedArticleIds: readonly string[];
readonly restoreDialogTargetId: string | null;
};
export type ArticleArchivePageVm = {
readonly readState: 'idle' | 'loading' | 'refreshing' | 'ready' | 'error';
readonly items: readonly (ArticleArchiveItemVm & {
readonly isExpanded: boolean;
})[];
readonly restoreDialog: {
readonly articleId: string;
readonly title: string;
} | null;
};
const toArticleArchivePageViewModel = ({ articles, readState, expandedArticleIds, restoreDialogTargetId }: { readonly articles: readonly Article[] | null; readonly readState: ArticleArchivePageVm['readState']; readonly expandedArticleIds: readonly string[]; readonly restoreDialogTargetId: string | null }): ArticleArchivePageVm => {
const items = (articles ?? []).flatMap((article) => {
const item = toArticleArchiveItemVm(article);
return item
? [
{
...item,
isExpanded: expandedArticleIds.includes(item.id),
},
]
: [];
});
const dialogTarget = items.find(({ id }) => id === restoreDialogTargetId);
return {
readState,
items,
restoreDialog: dialogTarget
? {
articleId: dialogTarget.id,
title: dialogTarget.title,
}
: null,
};
};

Der Archive Store bleibt genauso ruhig:

export const ArticleArchiveReadStore = signalStore(
withState<ArticleArchiveState>({
expandedArticleIds: [],
restoreDialogTargetId: null,
}),
withProps(() => ({
_catalog: inject(ArticleCatalogSourceStore),
})),
withComputed((store) => ({
vm: computed(() =>
toArticleArchivePageViewModel({
articles: store._catalog.articles(),
readState: store._catalog.readState(),
expandedArticleIds: store.expandedArticleIds(),
restoreDialogTargetId: store.restoreDialogTargetId(),
}),
),
})),
withMethods((store) => ({
toggleExpanded: (articleId: string): void => {
const expandedArticleIds = store.expandedArticleIds();
patchState(store, {
expandedArticleIds: expandedArticleIds.includes(articleId) ? expandedArticleIds.filter((id) => id !== articleId) : [...expandedArticleIds, articleId],
});
},
requestRestore: (articleId: string): void => {
patchState(store, {
restoreDialogTargetId: articleId,
});
},
closeRestoreDialog: (): void => {
patchState(store, {
restoreDialogTargetId: null,
});
},
})),
);

Beide View Read Stores lesen dieselbe Source.

Aber sie besitzen nicht denselben View-State.

Und sie treffen nicht dieselben Projektionsentscheidungen.

Der gemeinsame Read-State wird einmal im Source Store aus den sicheren Infrastructure-Signals abgeleitet.

Die View Read Stores müssen weder hasValue() noch HttpResourceRef.status() kennen.

Ein Catalog Reload löscht deshalb nicht versehentlich die Archive Expansion. Eine geänderte Overview-Sortierung invalidiert nicht das Reader-ViewModel. Ein Preview-Dialog erweitert nicht die öffentliche API eines globalen Article Stores.

Der Infrastructure-Adapter kapselt die Resource. Der Source Store teilt den gemeinsamen Source-Kontext. Die View Read Stores prägen daraus ihre use-case-spezifischen ViewModels aus.

Der Source Store besitzt den gemeinsamen Source-Kontext. Die View Read Stores besitzen die Bedeutung für ihren jeweiligen Use Case.

withComputed ist eine Grenze – bis es ein Integrationslayer wird

Abschnitt betitelt „withComputed ist eine Grenze – bis es ein Integrationslayer wird“

withComputed ist ein starker Ort, um ViewModel-Projektionen zu verdrahten.

Es liest Signals und leitet daraus neue Signals ab.

Die eigentlichen Projektionsregeln sollten dabei in benannten, puren Funktionen liegen.

Der Store soll beim Lesen möglichst nur zeigen:

Source Signals
+ View-owned State
+ optionaler Write-State
→ toWorkspacePageViewModel()

Ein langer computed-Block ist deshalb zunächst ein Lesbarkeitsproblem.

Die architektonisch wichtigere Frage lautet aber weiterhin: Wie viele unabhängige Verantwortungen laufen dort zusammen?

Kritisch wird es, wenn derselbe Store gleichzeitig diese Eingänge kennt:

Catalog
Workspace
Images
Identity
Locale
Route Context
Dialog State
Reader State
Write Pending

Dann verdrahtet withComputed nicht mehr nur die Projektion eines Use Cases.

Es wird zum internen Integrationslayer der gesamten Anwendung.

Jede neue View ergänzt noch einen Eingang. Jede neue Source erweitert den Testaufbau. Jede Änderung an einer Source kann scheinbar unabhängige Page-ViewModels betreffen.

Spätestens dann lohnt sich die Frage:

Orchestriert dieser Store noch eine zusammenhängende Projektion – oder koordiniert er bereits mehrere fachlich unabhängige Views?

Der Multi-Source-ViewModel-Artikel zeigt, wie ein ViewModel aus mehreren reaktiven Sources entstehen kann.

Mehrere Quellen sind dabei nicht das Problem.

Ein Workspace ViewModel darf Catalog, aktiven Article-Kontext und Identity lesen, wenn genau diese Inputs gemeinsam die Workspace-Ansicht ergeben.

Kritisch wird es erst, wenn derselbe Store außerdem noch Overview, Archive, Preview und Reader projiziert.

Nein.

Die neue Regel lautet nicht:

Jede gekapselte Resource bekommt einen Source Store.

Jede konkrete httpResource sollte zwar innerhalb eines Infrastructure-Adapters gekapselt bleiben.

Aber nicht jeder Adapter braucht darüber noch einen eigenen Source Store.

Das wäre nur eine neue Form von Endpoint-Arithmetik.

Mehrere Infrastructure-Adapter können sinnvoll zu einem Source Store gehören, wenn sie:

  • denselben fachlichen Kontext bedienen
  • gemeinsam aktiviert und deaktiviert werden
  • zusammen ein konsistentes Entity-Modell bilden
  • dieselben Consumer besitzen
  • gemeinsam invalidiert werden
  • bei fachlichen Änderungen meist gemeinsam angepasst werden

Ein Catalog kann beispielsweise aus einer Article Resource und einer Category Resource entstehen.

Wenn Overview und Archive beide dieses gemeinsame Catalog-Modell benötigen, kann ein ArticleCatalogSourceStore die beiden sicheren Source APIs orchestrieren.

Eine Trennung ist dagegen plausibel, wenn die Lebenszyklen auseinanderlaufen:

ArticleCatalogSourceStore
→ langlebiger Catalog
→ Overview und Archive
ArticleWorkspaceSourceStore
→ aktiver Article-/Section-Kontext
→ Workspace und Reader
ArticleImageSourceStore
→ Blob Cache
→ Object URLs
→ eigener Cleanup-Lifecycle

Drei Endpoints können einen Source Store ergeben.

Ein Infrastructure-Adapter kann indirekt mehrere View Read Stores bedienen.

Und ein kleiner View Read Store kann direkt einen Infrastructure-Adapter konsumieren, wenn keine gemeinsame Source-Ownership existiert.

Die Anzahl der HTTP-Aufrufe entscheidet nicht über die Anzahl der Stores.

Die bessere Frage lautet:

Würden diese Sources bei einer fachlichen Änderung gemeinsam aktiviert, gemeinsam verworfen und gemeinsam angepasst?

Wenn die Antwort regelmäßig „nein“ lautet, ist die gemeinsame Ownership wahrscheinlich nur technisch entstanden.

Mit mehreren Views kommt häufig eine weitere Frage hinzu:

Wer besitzt den aktiven Query-Kontext?

Nicht die Route.

Nicht die Komponente.

Nicht mehrere View Stores gleichzeitig.

Der Source Store besitzt die fachliche Query-Identität seiner gemeinsamen Source.

Der Infrastructure-Adapter setzt diese Query technisch in eine httpResource um.

Source Store
→ besitzt articleId, locale und Consumer-Kontext
→ entscheidet über Aktivierung und Invalidierung
Infrastructure-Adapter
→ übersetzt diesen Kontext in den HTTP-Request
→ kapselt den technischen Resource-Lifecycle

Views aktivieren einen konkreten Context:

Workspace View
→ activateWorkspace({ articleId, locale })
Reader View
→ activateReader({ articleId, locale })

Mehrere Consumer dürfen denselben Source-Kontext verwenden. Sie dürfen aber nicht unabhängig voneinander dieselbe Query-Wahrheit kopieren.

Ein guter Source Store kennt deshalb nicht nur Entities und Read-State.

Er kennt auch:

- die aktive Query-Identität
- die aktiven Consumer
- die Invalidierungsregeln
- die Korrelation eingehender Ergebnisse

Der Infrastructure-Adapter kennt dagegen:

- URL und Request-Form
- parse und DTO-Mapping
- hasValue(), value(), Loading und Error
- Reload und technische Abbruchsemantik

Diese Trennung verhindert einen besonders unangenehmen Fehler:

Query A startet
→ View wechselt zu Query B
→ Ergebnis A kommt spät
→ Ergebnis A wird als B dargestellt

Der Source Store darf nicht nur irgendeinen Wert teilen.

Er muss wissen, zu welcher fachlichen Query dieser Wert gehört.

Die Infrastructure muss sicherstellen, dass der technische Request genau diesen Kontext abbildet.

Das ist gemeinsame Source-Ownership ohne HttpResourceRef-Leakage.

Ein View Read Store besitzt Selection, Expansion und Dialog-Targets.

Er besitzt nicht den Router.

Ein fachlicher Intent darf so aussehen:

Overview
→ openArchive(locale)

Die tatsächliche Navigation bleibt außerhalb der Domain:

UI
→ Facade
→ Navigation Intent
→ Presentation Adapter
→ Router

Der View Read Store erzeugt keine URL.

Die Route darf beim Einstieg einen Context aktivieren.

Sie wird dadurch aber nicht zum dauerhaften State Owner.

Das hält zwei Richtungen auseinander:

Route
→ aktiviert einmalig den Domain-Kontext
Domain Intent
→ beschreibt eine gewünschte Navigation

Eine permanente Router↔Store-Synchronisierung ist dafür nicht nötig.


Bis hierhin ging es ausschließlich um Reads.

In realen Anwendungen zeigt eine View aber auch laufende Änderungen:

Restore läuft
Upload läuft
Kapitel wird angelegt
Seiten werden vorbereitet

Die naheliegende Abkürzung lautet:

Read Store
→ liest Command Store

Genau hier wird es unsauber.

Angular Resources besitzen für Reads bereits einen technischen Lifecycle:

value
status
isLoading
error

Der Infrastructure-Adapter kapselt diese API und stellt daraus sichere Source Signals bereit.

Ein klassischer HTTP-Command besitzt diesen Lifecycle nicht automatisch.

Wenn ein Command Store neben der HTTP-Ausführung auch noch pending, error und targetId hält, besitzt er plötzlich zwei Verantwortungen:

Command-Ausführung
und
darstellbarer Write-State

Der View Read Store hängt dann an der konkreten Implementierung des Executors.

Die sauberere Trennung lautet:

Command Request
→ Command Executor
→ Started
→ HTTP
→ Success / Failure Outcome

Der Executor besitzt die Ausführung.

Eine getrennte Write-State-Projektion besitzt den sichtbaren Lifecycle:

Started
+ Success / Failure Outcome
→ Write-State-Projektion
→ pending targets
→ UI-relevante Fehler

Der View Read Store darf diese Projektion readonly lesen.

Er kennt aber keinen Command Executor.


Diese Unterscheidung klingt klein.

Sie verhindert aber falschen State.

Ein Request beschreibt eine Intention:

restoreArticleRequested

Er beweist noch nicht, dass der Command tatsächlich angenommen wurde.

Bei exhaustMap kann ein zweiter Request verworfen werden, während der erste noch läuft.

Würde die Write-State-Projektion auf jeden Request mit pending = true reagieren, entstünde ein Zustand, für den niemals ein terminales Outcome kommt.

Deshalb:

Request
→ Executor nimmt tatsächlich an
→ Started
→ HTTP
→ Success oder Failure

Ein ignorierter Request erzeugt:

kein Started
kein Pending
kein später fehlendes Completion

Die Write-State-Projektion reagiert nicht auf Intentionen.

Sie reagiert auf tatsächlich angenommene Operationen.


Write-State ist eine Projektion, kein zweiter Command Store

Abschnitt betitelt „Write-State ist eine Projektion, kein zweiter Command Store“

Ein fachlich begrenzter Write-State kann beispielsweise so aussehen:

type ArticleCatalogWriteState = {
readonly restoringArticleIds: readonly string[];
};
export type ArticleCatalogWriteView = {
readonly restoringArticleIds: readonly string[];
readonly catalogPending: boolean;
};

Die konkreten Transitionen lauten:

restoreArticleStarted(articleId)
→ articleId hinzufügen
restoreArticleSucceeded(articleId)
→ articleId entfernen
restoreArticleFailed(articleId)
→ articleId entfernen

Nicht:

requested
→ pending = true

Und auch nicht:

lastResult
generic Operation Registry
Map<OperationType, OperationState>

Die Projektion hält nur den Zustand, den eine View wirklich darstellen muss.

Ein Upload kann eine konkrete uploadingPageId benötigen.

Ein Restore kann eine Menge aktiver Article IDs benötigen.

Ein globales isBusy hilft dagegen fast niemandem.


Damit entsteht ein realistischerer Schnitt:

Source Store
→ Entities
→ gemeinsamer Read-State
View-owned State
→ Selection
→ Expansion
→ Dialog Target
Write-State-Projektion
→ konkrete Pending Targets
→ sichtbare Write Errors

Diese drei Eingänge werden im View Read Store projiziert:

Source Store ─────────────────┐
View-owned State ─────────────┼→ View Read Store → Page VM → View
Write-State-Projektion ───────┘

Wichtig ist, was nicht verbunden ist:

View Read Store
✕ Command Executor

Der Read Store führt keine Commands aus.

Er dispatcht keine Command Requests.

Er kopiert keinen Command-Lifecycle.

Er liest nur eine readonly Projektion, wenn seine View diesen Zustand tatsächlich darstellen muss.

readState
→ gemeinsam normalisierter Read-Lifecycle
catalogPending
→ sichtbarer Write-Lifecycle
isRestorePending
→ use-case-spezifische VM-Ableitung

Der View Read Store projiziert Source-, View- und optionalen Write-State. Er konsumiert keinen Command Executor.

Der View Read Store projiziert Source-, View- und optionalen Write-State. Er konsumiert keinen Command Executor.


Ein terminales Command-Ergebnis kann mehrere Consumer haben:

Success / Failure Outcome
├→ Write-State-Projektion beendet Pending
├→ Source Store stößt passende Source-Invalidierung an
├→ Presentation schließt lokalen Dialog
└→ Presentation zeigt Notification

Das ist kein Grund, das Outcome dauerhaft im Read Store zu speichern.

Kein:

lastResult
lastSuccess
lastError
completionCounter

Ein Outcome ist ein Ereignis.

Ein ViewModel ist Zustand.

Die Presentation darf ein konkretes Outcome für eine lokale Completion konsumieren:

Create Chapter succeeded
→ passenden Dialog schließen

Sie darf daraus aber keinen neuen Domain-Workflow orchestrieren.

Der Source Store darf auf ein korreliertes Outcome reagieren:

Article A restored
→ passende Catalog-Source invalidieren
→ ArticleCatalogResource.reload()

Er darf dabei nicht irgendeine gerade zufällig aktive Query invalidieren.

Die Korrelation gehört zur Source-Ownership.

Die konkrete Reload-Mechanik bleibt im Infrastructure-Adapter.


Es gibt keinen einzelnen Messwert, ab dem ein Store geteilt werden muss.

Es gibt aber wiederkehrende Hinweise.

Mehrere Views interpretieren dieselben Entities unterschiedlich

Abschnitt betitelt „Mehrere Views interpretieren dieselben Entities unterschiedlich“

Overview versteckt archivierte Artikel.

Archive bietet Restore an.

Workspace sperrt Bearbeitung.

Preview verhindert Veröffentlichung.

Die Datenquelle ist gemeinsam. Die Bedeutung ist es nicht.

Der Catalog bleibt während der Navigation geladen.

Der Reader Index gilt nur für eine geöffnete Lesesitzung.

Die Archive Expansion soll einen Reload überleben, aber beim Verlassen der Page verschwinden.

Diese Zustände haben unterschiedliche Lebenszyklen.

Overview, Archive und Preview verwenden denselben Catalog, werden aber unabhängig gerendert und weiterentwickelt.

Eine Änderung für einen Consumer erweitert regelmäßig den Testaufbau der anderen.

Ein Reload der Resource soll Filter, Sortierung, Expansion oder Selection nicht automatisch zurücksetzen.

Dann ist der View-State kein Teil des Resource-Lifecycles.

Für eine neue Archive-Aktion müssen Tests für Overview, Workspace und Preview angepasst werden, weil alle denselben Store instanziieren.

Der Store macht unabhängige Änderungen künstlich abhängig.

Query- und Invalidierungsregeln laufen auseinander

Abschnitt betitelt „Query- und Invalidierungsregeln laufen auseinander“

Der Catalog hängt von Locale und Veröffentlichungsbereich ab.

Der Workspace hängt von Article ID, Section ID und Bearbeitungskontext ab.

Beides in einer gemeinsamen Query zu halten erzeugt Kombinationen, die fachlich nichts miteinander zu tun haben.

Der Infrastructure-Adapter stellt bereits sichere Signals für Daten, Loading und Error bereit.

Der Store hält daneben:

articles: Article[];
articlesLoading: boolean;
articlesLoaded: boolean;
articlesError: string | null;

Dann existieren zwei Wahrheiten über denselben Read-Lifecycle.

Die Kapselung der httpResource rechtfertigt keine zweite State Machine im Store.

Wenn ein Page-ViewModel nur noch verständlich ist, nachdem man Catalog, Images, Identity, Route, Locale, Dialoge und mehrere Pending-Zustände rekonstruiert hat, ist die Projektion wahrscheinlich zu breit geworden.

Keines dieser Signale beweist allein, dass eine Trennung nötig ist.

Gemeinsam zeigen sie aber, dass der ursprüngliche Owner mehrere Änderungsgründe angesammelt hat.


Der Gegenfall ist genauso wichtig.

Ein einzelner Store darf bleiben, wenn:

  • nur eine View existiert
  • Source und View immer gemeinsam leben
  • kein anderer Consumer die Entities benötigt
  • der View-State klein bleibt
  • Query und Invalidierung eindeutig sind
  • das withComputed eine kohärente Projektion bildet
  • eine Trennung fast nur Durchreicher erzeugen würde

Ein View Read Store mit gekapselter Infrastructure-Source, Selection und ViewModel ist nicht automatisch vermischt.

Wenn die Selection ausschließlich zu dieser einen View gehört und die Source keinen weiteren Consumer hat, besitzen beide möglicherweise denselben sinnvollen Lebenszyklus.

Dann würde ein zusätzlicher Source Store nur dazu führen, dass der View Read Store sichere Signals aus einer anderen Datei wieder zusammensetzt.

Das ist keine gewonnene Grenze.

Das ist verteilte Einfachheit.

Architektur sollte kein erwartetes Problem lösen, das noch nicht existiert.

Ein kleiner Use Case darf klein bleiben.


Eine Architektur ist nicht allein deshalb gut, weil ihre Pfeile plausibel aussehen.

Die Grenzen müssen überprüfbar sein.

Testet:

- DTO→Entity-Mapping
- technischer hasValue()/value()-Guard
- null gegenüber erfolgreich geladenem []
- Loading und Error als sichere Signals
- Reload delegiert an die private Resource
- keine HttpResourceRef verlässt die Infrastructure

Testet:

- Query-Aktivierung
- gemeinsamer Read-State
- Ergebnis-Korrelation
- Reload und Invalidierung
- Deaktivierung ohne Consumer
- kein kopierter Resource-State

Testet:

- owned View-State
- Source+View-State→ViewModel-Projektion
- Selection
- Expansion
- Dialog Targets
- Read Loading und Write Pending getrennt
- keine technische Resource-Semantik im Store

Testet:

- Request wird angenommen oder verworfen
- Started entsteht erst bei tatsächlicher Annahme
- genau ein Success- oder Failure-Outcome
- kein UI-State im Executor

Testet:

- Started aktiviert den passenden Target-State
- Success und Failure beenden nur das passende Target
- Mismatches verändern keinen fremden State
- parallele Operationen bleiben getrennt

Testet die Architektur-Invariante:

Write-State-Projektionen sind aktiv,
bevor ein Executor synchron Started emittiert.

Gerade eventbasierte Projektionen können korrekt aussehen und trotzdem an einer ungetesteten Bootstrap-Reihenfolge hängen.

Eine Architektur ohne Absicherung bleibt eine Behauptung.

Eine mögliche Struktur sieht dann so aus:

article/
├── entities/
│ └── article.ts
├── infrastructure/
│ ├── article.dto.ts
│ ├── article.mapper.ts
│ ├── article-catalog.resource.ts
│ └── article.commands.ts
├── source-state/
│ ├── article-catalog-source.store.ts
│ └── article-workspace-source.store.ts
├── overview/
│ ├── article-overview.vm.ts
│ ├── article-overview.mapper.ts
│ └── article-overview-read.store.ts
├── archive/
│ ├── article-archive.vm.ts
│ ├── article-archive.mapper.ts
│ └── article-archive-read.store.ts
├── command/
│ └── article-catalog-command.store.ts
└── write-state/
└── article-catalog-write-state.store.ts

Die Ordnernamen sind nicht die Regel.

Die Richtung ist entscheidend:

DTO
→ Entity
→ gekapselte Source Signals
→ optionaler Source State
→ View Projection
→ Page ViewModel
→ Presentation

Für Writes:

Intent
→ Request
→ Command Executor
→ Started / Outcome
→ Write-State-Projektion

Der Infrastructure-Adapter kennt kein ViewModel.

Der Source Store kennt keine Overview-Komponente.

Der Overview Store kennt keine API-Payload und keine HttpResourceRef.

Der Archive Mapper entscheidet nicht über HTTP-Invalidierung.

Der Command Executor besitzt keinen UI-State.

Der View Read Store kennt keinen Command Executor.

Die Presentation interpretiert keine Resource.

Jede Grenze beantwortet eine andere Frage.


Die Store-Grenze kann sich mit der Anwendung verändern.

Die Modellgrenzen sollten das nicht.

Auch ein kleiner Slice bleibt:

DTO
→ Entity
→ ViewModel

Vielleicht liegen Entity und ViewModel zunächst in einem einzigen Read Store.

Das ist okay.

Vielleicht werden Source State und View-Projektion später getrennt.

Auch das ist okay.

Was nicht passieren sollte:

DTO
→ überall

Oder:

Entity
→ Template entscheidet die Bedeutung

Oder:

HttpResourceRef
→ leakt in den Store
→ Store rekonstruiert technischen Lifecycle

Oder:

Read Store
→ Command Executor

Der passende Merksatz lautet deshalb:

Modellgrenzen sind verbindlich. Store-Grenzen folgen dem tatsächlichen Ownership- und Skalierungsbedarf.

Nicht jeder Infrastructure-Adapter braucht einen Source Store.

Nicht jede View braucht einen eigenen View Read Store.

Nicht jede wachsende Datei braucht sofort eine Aufteilung.

Aber wenn unterschiedliche Lebenszyklen, Consumer und Bedeutungen denselben Owner teilen, sollte man nicht auf die nächste Tausend-Zeilen-Marke warten.


Ein Read Store wird nicht dadurch falsch, dass die Anwendung wächst.

Er kann nur irgendwann eine Grenze besitzen, die nicht mehr zur gewachsenen Anwendung passt.

Dann geht es nicht darum, den Store möglichst klein zu schneiden.

Es geht darum, Verantwortung wieder sichtbar zu machen.

Der Infrastructure-Adapter besitzt die konkrete HttpResourceRef und ihren technischen Lifecycle.

Der Source Store besitzt den gemeinsamen fachlichen Source-Kontext, seine Query, Invalidierung und geteilten Entities.

Der View Read Store besitzt den Zustand und die Projektion eines konkreten Use Cases.

Der Command Executor besitzt die Ausführung einer Änderung.

Die Write-State-Projektion besitzt den sichtbaren Lifecycle einer tatsächlich angenommenen Änderung.

Das Page-ViewModel darf Source-, View- und Write-State zusammenführen.

Aber Zusammenführen bedeutet nicht Besitzen.

Und es bedeutet schon gar nicht:

Read Store
→ Command Executor

Oder:

View Read Store
→ HttpResourceRef

Ein großer Store kann kohärent sein.

Ein kleiner Store kann bereits mehrere falsche Owner enthalten.

Die entscheidende Frage lautet deshalb nicht:

Wie viele Zeilen hat der Store?

Sondern:

Welche Zustände würden wir bei einer fachlichen Änderung tatsächlich gemeinsam ändern, aktivieren und verwerfen?

Wenn diese Frage keine gemeinsame Antwort mehr hat, ist der Store wahrscheinlich nicht zu groß.

Seine Ownership ist zu breit.