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 → ViewFü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.
Der Store war nicht falsch
Abschnitt betitelt „Der Store war nicht falsch“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:
OverviewArchiveWorkspacePreviewReaderAlle 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 und Bedeutungen.
Das Problem ist nicht die Zeilenzahl
Abschnitt betitelt „Das Problem ist nicht die Zeilenzahl“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 LesesitzungDiese 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.
DTO, Entity und ViewModel bleiben drei Modelle
Abschnitt betitelt „DTO, Entity und ViewModel bleiben drei Modelle“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 → PresentationDiese Modelltrennung hängt nicht von der Größe des Use Cases ab.
Sie gilt auch dann, wenn ein einziger Store vollkommen ausreicht.
Das DTO beschreibt den Transport
Abschnitt betitelt „Das DTO beschreibt den Transport“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.
Die Entity beschreibt das Frontend-Fachmodell
Abschnitt betitelt „Die Entity beschreibt das Frontend-Fachmodell“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.
Das ViewModel gehört keiner Entity
Abschnitt betitelt „Das ViewModel gehört keiner Entity“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 erzeugenDie 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 └→ ArticlePreviewVmGenau 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 bereitDer technische Resource-Lifecycle bleibt in der Infrastructure.
Der Source Store besitzt die Bedeutung dieser Quelle innerhalb des Frontend-SCS.
Infrastructure-Adapter
Abschnitt betitelt „Infrastructure-Adapter“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(): voidhasValue() 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.
Source Store
Abschnitt betitelt „Source Store“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-StateDer 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 besitzt die Projektion
Abschnitt betitelt „Der View Read Store besitzt die Projektion“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 ViewModelEr 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-LifecycleDer View Read Store ist die Projektionsstelle.
Er ist nicht das Endlager der gesamten Anwendung.
Zwei Views, zwei Projektionen
Abschnitt betitelt „Zwei Views, zwei Projektionen“Overview und Archive verwenden dieselben Article Entities.
Ihre Mapper treffen aber unterschiedliche Entscheidungen.
Overview-Projektion
Abschnitt betitelt „Overview-Projektion“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.
Archive-Projektion
Abschnitt betitelt „Archive-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.
Zwei Views, zwei Read Stores
Abschnitt betitelt „Zwei Views, zwei Read Stores“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 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:
CatalogWorkspaceImagesIdentityLocaleRoute ContextDialog StateReader StateWrite PendingDann 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.
Eine Resource pro Store?
Abschnitt betitelt „Eine Resource pro Store?“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-LifecycleDrei 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.
Der Source Store besitzt auch den Query-Lifecycle
Abschnitt betitelt „Der Source Store besitzt auch den Query-Lifecycle“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-LifecycleViews 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 ErgebnisseDer Infrastructure-Adapter kennt dagegen:
- URL und Request-Form- parse und DTO-Mapping- hasValue(), value(), Loading und Error- Reload und technische AbbruchsemantikDiese Trennung verhindert einen besonders unangenehmen Fehler:
Query A startet→ View wechselt zu Query B→ Ergebnis A kommt spät→ Ergebnis A wird als B dargestelltDer 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.
Navigation ist kein Read-State
Abschnitt betitelt „Navigation ist kein Read-State“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→ RouterDer 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 NavigationEine permanente Router↔Store-Synchronisierung ist dafür nicht nötig.
Commands gehören nicht in den Read Store
Abschnitt betitelt „Commands gehören nicht in den Read Store“Bis hierhin ging es ausschließlich um Reads.
In realen Anwendungen zeigt eine View aber auch laufende Änderungen:
Restore läuftUpload läuftKapitel wird angelegtSeiten werden vorbereitetDie naheliegende Abkürzung lautet:
Read Store→ liest Command StoreGenau hier wird es unsauber.
Angular Resources besitzen für Reads bereits einen technischen Lifecycle:
valuestatusisLoadingerrorDer 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ührungunddarstellbarer Write-StateDer View Read Store hängt dann an der konkreten Implementierung des Executors.
Die sauberere Trennung lautet:
Command Request → Command Executor → Started → HTTP → Success / Failure OutcomeDer 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 FehlerDer View Read Store darf diese Projektion readonly lesen.
Er kennt aber keinen Command Executor.
Request ist nicht Started
Abschnitt betitelt „Request ist nicht Started“Diese Unterscheidung klingt klein.
Sie verhindert aber falschen State.
Ein Request beschreibt eine Intention:
restoreArticleRequestedEr 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 FailureEin ignorierter Request erzeugt:
kein Startedkein Pendingkein später fehlendes CompletionDie 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 entfernenNicht:
requested → pending = trueUnd auch nicht:
lastResultgeneric Operation RegistryMap<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.
Das vollständige Page-ViewModel
Abschnitt betitelt „Das vollständige Page-ViewModel“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 ErrorsDiese drei Eingänge werden im View Read Store projiziert:
Source Store ─────────────────┐View-owned State ─────────────┼→ View Read Store → Page VM → ViewWrite-State-Projektion ───────┘Wichtig ist, was nicht verbunden ist:
View Read Store ✕ Command ExecutorDer 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.
Outcomes bleiben ephemer
Abschnitt betitelt „Outcomes bleiben ephemer“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 NotificationDas ist kein Grund, das Outcome dauerhaft im Read Store zu speichern.
Kein:
lastResultlastSuccesslastErrorcompletionCounterEin 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ßenSie 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.
Hinweise, dass die Grenze nicht mehr passt
Abschnitt betitelt „Hinweise, dass die Grenze nicht mehr passt“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.
Source und View leben unterschiedlich lange
Abschnitt betitelt „Source und View leben unterschiedlich lange“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.
Der Store hat unabhängige Consumer
Abschnitt betitelt „Der Store hat unabhängige Consumer“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.
View-State bleibt trotz Reload relevant
Abschnitt betitelt „View-State bleibt trotz Reload relevant“Ein Reload der Resource soll Filter, Sortierung, Expansion oder Selection nicht automatisch zurücksetzen.
Dann ist der View-State kein Teil des Resource-Lifecycles.
Kleine Änderungen erzeugen einen großen Radius
Abschnitt betitelt „Kleine Änderungen erzeugen einen großen Radius“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.
Source-State wird kopiert
Abschnitt betitelt „Source-State wird kopiert“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.
withComputed kennt die halbe Anwendung
Abschnitt betitelt „withComputed kennt die halbe Anwendung“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.
Wann die Trennung nur Overhead produziert
Abschnitt betitelt „Wann die Trennung nur Overhead produziert“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
withComputedeine 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.
Was die Trennung testbar macht
Abschnitt betitelt „Was die Trennung testbar macht“Eine Architektur ist nicht allein deshalb gut, weil ihre Pfeile plausibel aussehen.
Die Grenzen müssen überprüfbar sein.
Infrastructure-Adapter
Abschnitt betitelt „Infrastructure-Adapter“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 InfrastructureSource Store
Abschnitt betitelt „Source Store“Testet:
- Query-Aktivierung- gemeinsamer Read-State- Ergebnis-Korrelation- Reload und Invalidierung- Deaktivierung ohne Consumer- kein kopierter Resource-StateView Read Store
Abschnitt betitelt „View Read Store“Testet:
- owned View-State- Source+View-State→ViewModel-Projektion- Selection- Expansion- Dialog Targets- Read Loading und Write Pending getrennt- keine technische Resource-Semantik im StoreCommand Executor
Abschnitt betitelt „Command Executor“Testet:
- Request wird angenommen oder verworfen- Started entsteht erst bei tatsächlicher Annahme- genau ein Success- oder Failure-Outcome- kein UI-State im ExecutorWrite-State-Projektion
Abschnitt betitelt „Write-State-Projektion“Testet:
- Started aktiviert den passenden Target-State- Success und Failure beenden nur das passende Target- Mismatches verändern keinen fremden State- parallele Operationen bleiben getrenntComposition Root
Abschnitt betitelt „Composition Root“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.
Der konkrete Schnitt
Abschnitt betitelt „Der konkrete Schnitt“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.tsDie Ordnernamen sind nicht die Regel.
Die Richtung ist entscheidend:
DTO → Entity → gekapselte Source Signals → optionaler Source State → View Projection → Page ViewModel → PresentationFür Writes:
Intent → Request → Command Executor → Started / Outcome → Write-State-ProjektionDer 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.
Modellgrenzen sind verbindlich
Abschnitt betitelt „Modellgrenzen sind verbindlich“Die Store-Grenze kann sich mit der Anwendung verändern.
Die Modellgrenzen sollten das nicht.
Auch ein kleiner Slice bleibt:
DTO → Entity → ViewModelVielleicht 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 → überallOder:
Entity → Template entscheidet die BedeutungOder:
HttpResourceRef → leakt in den Store → Store rekonstruiert technischen LifecycleOder:
Read Store → Command ExecutorDer 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 ExecutorOder:
View Read Store → HttpResourceRefEin 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.