Retrieve Slice: Daten laden ohne Komponentenlogik
Daten laden klingt harmlos.
Ein GET, ein bisschen Loading, vielleicht ein Error State, fertig.
Bis die Komponente plötzlich mehr macht als rendern:
- sie kennt HTTP-Endpunkte
- sie unterscheidet technische Ladezustände
- sie mappt DTOs
- sie baut ein Modell für das Template
- sie entscheidet über Empty State
- sie zeigt Fehlertexte
- sie ruft irgendwann noch
reload()auf
Das funktioniert.
Bis der nächste Flow dazukommt.
Dieser Artikel zeigt deshalb einen bewusst geschnittenen Retrieve-Slice mit einem echten HTTP-Endpunkt:
https://lorem-api.com/api/article/fooDer Slice nutzt:
httpResourcein der Infrastructure- NgRx Signal Store in
+state - eine dünne Application-Facade
- eine Presentation, die nur noch Signale rendert
Nicht als allgemeine Wahrheit.
Sondern als konkreter Schnitt, über den man im Team reden kann.
Der Schnitt
Abschnitt betitelt „Der Schnitt“
Der wichtigste Punkt:
httpResource ist Teil der technischen API-Grenze.
httpResource ist kein normales UI-Signal.
Angular beschreibt httpResource als reaktiven Wrapper um HttpClient. Es erstellt HTTP-Requests und stellt Response, Request-Status und Fehler als Signals bereit. Weil httpResource auf HttpClient aufsetzt, läuft es außerdem durch den Angular-HTTP-Stack inklusive Interceptors.
Damit liegt httpResource fachlich näher an der externen API als an der Presentation.
Genau deshalb gehört sie in diesem Slice in die Infrastructure.
Nicht, weil sie schlecht oder „schmutzig“ wäre.
Sondern weil sie externen Zugriff modelliert.
Wenn die Presentation eine httpResource kennt, kennt sie automatisch technische Details der API-Grenze:
hasValue()value()isLoading()error()reload()- Request-Status
- Parse-Verhalten
Das ist API-Leakage.
Die Komponente würde dann nicht mehr nur ein ViewModel rendern. Sie würde anfangen, die Semantik einer HTTP-Resource zu interpretieren.
Deshalb soll die Presentation keine httpResource kennen. Die Application auch nicht.
Und auch der Store sollte die konkrete HttpResourceRef nicht interpretieren müssen.
Die Infrastructure hält sie privat und stellt nach außen eine kleine, benannte Signal-API bereit:
- den aktuell lesbaren Artikel
- den Ladezustand
- den Fehlerzustand
- eine explizite Reload-Operation
Der Store konsumiert diese API.
Er kennt die Quelle.
Aber nicht mehr deren Framework-Mechanik.
Beispiel-Domäne: Article
Abschnitt betitelt „Beispiel-Domäne: Article“Die API liefert einen Artikel.
Die externe Antwort enthält unter anderem:
slugtitlesubtitleimageauthorcontent
Das ist das API-Modell.
Das Frontend arbeitet intern mit einer Entity.
Die UI rendert ein ViewModel.
Das sind drei verschiedene Dinge.
API DTO ↓ infrastructure mapperEntity ↓ state mapperViewModel ↓ presentationTemplateDiese Trennung ist der Kern des Slice.
Der Anti-Corruption-Layer-Artikel erklärt, warum fremde Modelle nicht ungefiltert durch das Frontend wandern sollten. Hier geht es nicht noch einmal um das Warum, sondern um den konkreten Ort im Code.
Zielstruktur
Abschnitt betitelt „Zielstruktur“Eine mögliche Struktur für den Slice:
article/├── entities/│ └── article.model.ts├── infrastructure/│ ├── article.dto.ts│ ├── article.mapper.ts│ └── article.resource.ts├── +state/│ ├── article.store.ts│ ├── article.vm.ts│ └── article-view-model.mapper.ts├── application/│ └── article.facade.ts└── presentation/ ├── article-page.component.ts └── article-page.component.htmlDie Namen sind nicht magisch.
Wichtig ist die Richtung der Verantwortung:
presentation → application → +state → infrastructureDie obere Schicht kennt die darunterliegende Abstraktion.
Aber sie greift nicht quer durch alle Schichten.
1. Infrastructure: httpResource und DTO-Grenze
Abschnitt betitelt „1. Infrastructure: httpResource und DTO-Grenze“Die Infrastructure kennt die externe API.
Sie weiß:
- wo geladen wird
- welches DTO erwartet wird
- wo die Antwort in eine interne Entity übersetzt wird
In einem produktiven System kann an dieser Grenze zusätzlich Runtime Validation stattfinden.
Zum Beispiel mit Zod, Valibot oder einer vergleichbaren Bibliothek.
Für diesen ersten Slice bleibt das bewusst außen vor.
Nicht, weil Validation unwichtig wäre.
Sondern weil dieser Artikel den Schichtenschnitt zeigen soll.
Runtime Validation ist wichtig, aber nicht der Fokus dieses ersten Slice.
article.dto.ts
Abschnitt betitelt „article.dto.ts“export interface ArticleAuthorDto { readonly id: string; readonly name: string; readonly avatar: string; readonly email: string;}
export interface ArticleDto { readonly slug: string; readonly title: string; readonly subtitle: string; readonly image: string; readonly author: ArticleAuthorDto; readonly content: string;}Das DTO beschreibt nicht die Wahrheit des Frontends.
Es beschreibt nur den Vertrag der API.
Wenn die API image liefert, muss die UI später nicht zwingend denselben Feldnamen verwenden.
article.model.ts
Abschnitt betitelt „article.model.ts“Die fachliche Form des Slice liegt hier in entities/.
Das folgt bewusst der DDD-nahen Angular-Terminologie, wie sie unter anderem Manfred Steyer verwendet. Für diesen Artikel ist daran aber nichts Mystisches: entities/ enthält die internen fachlichen Modelle, mit denen der Slice arbeitet.
Die Infrastructure kennt DTOs.
Der Rest des Slice arbeitet mit Entities.
export interface ArticleAuthor { readonly id: string; readonly name: string; readonly avatarUrl: string; readonly email: string;}
export interface Article { readonly slug: string; readonly title: string; readonly subtitle: string; readonly heroImageUrl: string; readonly author: ArticleAuthor; readonly paragraphs: readonly string[];}Man kann darüber streiten, ob dieser Ordner entities, model oder domain heißt.
Der entscheidende Punkt ist nicht der Ordnername.
Der entscheidende Punkt ist:
Die Entity ist nicht das DTO.
article.mapper.ts
Abschnitt betitelt „article.mapper.ts“Die Infrastructure mappt von außen nach innen.
Hier passiert bewusst beides:
parseArticleResponse()toDomain()
import { Article, ArticleAuthor } from '../entities/article.model';import { ArticleAuthorDto, ArticleDto } from './article.dto';
export const parseArticleResponse = (value: unknown): Article => toDomain(value as ArticleDto);
export const toDomain = ({ slug, title, subtitle, image, author, content }: ArticleDto): Article => ({ slug, title, subtitle, heroImageUrl: image, author: toAuthorDomain(author), paragraphs: toParagraphs(content),});
const toAuthorDomain = ({ id, name, avatar, email }: ArticleAuthorDto): ArticleAuthor => ({ id, name, avatarUrl: avatar, email,});
const toParagraphs = (content: string): readonly string[] => content .split(/\n+/) .map((paragraph) => paragraph.trim()) .filter(Boolean);parseArticleResponse() nutzt hier bewusst einen Typecast:
value as ArticleDto;Das ist keine Runtime Validation.
Der Cast sagt nur dem TypeScript-Compiler:
Behandle diesen Wert ab hier als
ArticleDto.
Ob die API zur Laufzeit wirklich dieses Format liefert, wird dadurch nicht geprüft.
Für diesen ersten Retrieve-Slice ist das Absicht. Der Artikel soll den Schichtenschnitt zeigen: httpResource, Infrastructure-Mapping, Signal Store, ViewModel und Presentation.
In einem produktiven System würde ich diese Grenze robuster machen — zum Beispiel mit Zod, Valibot, io-ts oder einer manuellen Type-Guard-Prüfung.
Das wäre aber ein eigener Artikel.
Hier reicht der Typecast als bewusst vereinfachte Stelle, an der später echte Runtime Validation ergänzt werden kann.
Wichtig ist hier:
unknown API response → ArticleDto → ArticleNach dieser Datei ist das API-Modell weg.
Der Rest des Frontends arbeitet mit Article.
article.resource.ts
Abschnitt betitelt „article.resource.ts“Jetzt kommt httpResource.
import { computed, Injectable } from '@angular/core';import { httpResource } from '@angular/common/http';
import { Article } from '../entities/article.model';import { parseArticleResponse } from './article.mapper';
@Injectable()export class ArticleResource { private readonly resource = httpResource<Article>( () => ({ url: 'https://lorem-api.com/api/article/foo', method: 'GET', }), { parse: parseArticleResponse, }, );
readonly article = computed<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(); }}Diese Klasse ist absichtlich eine Klasse.
Natürlich könnte man den Wrapper auch als Factory Function schreiben.
httpResource braucht aber einen Angular Injection Context und erzeugt eine zustandsbehaftete Quelle. Die Klasse macht deshalb direkt sichtbar:
- Angular verwaltet die Instanz
- der Provider bestimmt ihren Scope
- die konkrete Resource bleibt privat
- nach außen gehen nur definierte Signals und Operationen
Für diesen Slice ist das lesbarer als eine Factory, deren Injection Context und Lebenszyklus erst aus dem Aufrufort hervorgehen.
Die eigentliche Angular Resource bleibt ein Implementierungsdetail:
private HttpResourceRef ↓ArticleResource ↓article | isLoading | error | reloadDas ist mehr als ein Wrapper um eine Methode.
HttpResourceRef besitzt eigene technische Semantik:
value()darf nicht in jedem Zustand ungesichert gelesen werdenhasValue()schützt diesen Zugriff- Loading und Error gehören zum asynchronen Lifecycle
reload()ist eine Fähigkeit der konkreten technischen Quelle
Diese Semantik wird genau einmal an der Infrastructure-Grenze behandelt.
Der Store muss sie danach nicht erneut interpretieren.
Das öffentliche article-Signal liefert deshalb:
Article | nullnull bedeutet hier nicht:
Die API hat einen leeren Artikel geliefert.
Es bedeutet:
Die technische Quelle hat aktuell keinen lesbaren Artikel.
Sobald ein Article vorhanden ist, muss er ein gültiges internes Modell sein. Dafür ist die Infrastructure-Grenze verantwortlich.
Ein künstliches EMPTY_ARTICLE wäre bei einem einzelnen Modell meist keine bessere Lösung. Es würde zwar den TypeScript-Typ erfüllen, aber schnell eine Entity mit leerer ID, erfundenen Pflichtwerten oder ungültigen Invarianten erzeugen.
Bei Collections kann ein leeres Array ein neutraler Wert sein.
Bei einer einzelnen Entity ist Abwesenheit meistens ehrlicher als ein erfundenes Domain Model.
Auch reload() bleibt bewusst in der Infrastructure-API:
reload(): void { this.resource.reload();}Der Wrapper erfindet keinen fachlichen Use Case.
Er verhindert lediglich, dass der Store die konkrete HttpResourceRef kennen muss, nur um dieselbe Quelle erneut zu laden.
Der Store muss keine URL kennen.
Er muss nicht wissen, wie value() abgesichert wird.
Die Presentation muss keine Resource kennen.
Die Application muss keine HTTP-Details kennen.
httpResource ist hier kein globaler Zauber, der überall im Frontend herumgereicht wird.
Es ist die private technische Implementierung einer klaren Infrastructure-API.
2. +state: Source Signals orchestrieren, ViewModel ableiten
Abschnitt betitelt „2. +state: Source Signals orchestrieren, ViewModel ableiten“Der Store konsumiert jetzt nicht mehr die Angular Resource.
Er konsumiert die öffentliche API des Infrastructure-Adapters.
Das ist der eigentliche Schnitt.
Schlecht wäre eine öffentlich durchgereichte HttpResourceRef:
readonly articleResource = httpResource<Article>(...);Dann müsste jede konsumierende Schicht deren Lifecycle neu interpretieren.
Besser ist:
private httpResource ↓sichere Infrastructure-Signals ↓Store-Orchestrierung ↓ViewModelDer Store exponiert keine httpResource.
Und er enthält auch keine technische Fallunterscheidung über deren Zustand.
article.vm.ts
Abschnitt betitelt „article.vm.ts“Das ViewModel beschreibt, was die Page wirklich braucht.
export interface ArticleAuthorVm { readonly name: string; readonly avatarUrl: string; readonly emailLabel: string;}
export interface ArticleVm { readonly title: string; readonly subtitle: string; readonly heroImageUrl: string; readonly author: ArticleAuthorVm; readonly paragraphs: readonly string[];}Dieses Modell ist UI-orientiert.
emailLabel ist keine Backend-Sprache.
Das ViewModel ist eine bewusste Entscheidung der UI. Der Grundlagenartikel ViewModel Aggregation beschreibt diese Idee ausführlicher. Hier interessiert nur: Wo entsteht es im Code?
loading und error sind in diesem Slice bewusst nicht Teil des ViewModels.
Sie entstehen aus der Resource und werden vom Store als eigene Signale bereitgestellt. Das ViewModel bleibt hier eine einfache Projektion:
Article | null → ArticleVm | nullNatürlich kann es an anderer Stelle sinnvoll sein, komplexere Page-State-Modelle zu bauen: differenzierte Fehlerzustände, Partial Loading, Empty States, Retry-Modelle, Skeleton-Konfigurationen oder fachliche Ladephasen.
Das ist aber nicht der Fokus dieses ersten Retrieve-Slice.
Hier geht es um die saubere Trennung:
- Infrastructure übersetzt externe DTOs in Entities.
ArticleResourcekapselt den technischen Resource-Lifecycle.- Der Store orchestriert benannte Source Signals und leitet ein Read Model ab.
- ViewModel-Mapping bleibt eine einfache Projektion.
- Presentation reagiert auf Facade-Signale.
article-view-model.mapper.ts
Abschnitt betitelt „article-view-model.mapper.ts“Das Mapping von Entity zu ViewModel liegt auf State-Ebene.
Nicht in der Infrastructure.
Nicht in der Komponente.
import { Article } from '../entities/article.model';import { ArticleVm } from './article.vm';
export const toArticleViewModel = (article: Article | null): ArticleVm | null => { if (article === null) { return null; }
const { title, subtitle, heroImageUrl, author, paragraphs } = article;
return { title, subtitle, heroImageUrl, author: { name: author.name, avatarUrl: author.avatarUrl, emailLabel: author.email, }, paragraphs, };};Der Mapper projiziert einen optional vorhandenen Artikel in ein optional vorhandenes ViewModel.
Er interpretiert keinen HTTP-Status.
Er entscheidet nicht, warum gerade kein Artikel vorliegt.
Er erhält nur die bereits normalisierte Source:
Article | null ↓ArticleVm | nullDie Domain Entity selbst bleibt strikt.
Article enthält kein null, keine leeren Pflichtfelder und keinen technischen Ladezustand.
Hier wird eine zweite Grenze sichtbar:
Infrastructure:DTO → EntityHttpResourceRef → sichere Source Signals
+state:Entity | null → ViewModel | nullDas sind nicht dieselben Mapper.
Sie beantworten unterschiedliche Fragen.
Die Infrastructure fragt:
Was hat die API geliefert, wie übersetzen wir es in unsere interne Sprache, und wie stellen wir den technischen Lifecycle sicher bereit?
Der State fragt:
Was braucht die UI, um diesen Artikel sauber zu rendern?
article.store.ts
Abschnitt betitelt „article.store.ts“Der Store orchestriert die Source Signals.
import { computed, inject } from '@angular/core';import { signalStore, withComputed, withMethods, withProps } from '@ngrx/signals';
import { ArticleResource } from '../infrastructure/article.resource';import { toArticleViewModel } from './article-view-model.mapper';
export const ArticleStore = signalStore( withProps(() => ({ _articleResource: inject(ArticleResource), })),
withComputed(({ _articleResource }) => ({ isLoading: _articleResource.isLoading, error: _articleResource.error, vm: computed(() => toArticleViewModel(_articleResource.article())), })),
withMethods(({ _articleResource }) => ({ reload: (): void => _articleResource.reload(), })),);Der Lesefluss ist jetzt absichtlich ruhig.
Man sieht unmittelbar:
article → ViewModelisLoading → Store-APIerror → Store-APIreload → InfrastructureDazwischen steht keine technische Condition mehr.
Nicht, weil der Lifecycle verschwunden wäre.
Sondern weil er bereits dort normalisiert wurde, wo er entsteht.
Der Store kennt deshalb weder:
hasValue()- das Wurfverhalten von
value() ResourceStatus- die konkrete
HttpResourceRef - Parse- oder Request-Details
Er konsumiert benannte Signals und eine explizite Operation.
Das ist die gewünschte Rolle des Stores in diesem Slice:
Der Store interpretiert keinen technischen Resource-Lifecycle. Er orchestriert Quellen, Projektionen und Intents.
Ganz ohne Entscheidungen ist er deshalb nicht.
Er entscheidet weiterhin:
- welche Signale der Slice öffentlich macht
- welches ViewModel abgeleitet wird
- welche Operationen als Slice-Intents angeboten werden
Aber er enthält keine fallweise Entscheidung darüber, ob eine Angular Resource gerade sicher gelesen werden darf.
Diese Unterscheidung verbessert den Lesefluss.
Beim Lesen des Stores interessiert die Architektur des Read-Flows:
Source Signal → ViewModel ProjectionNicht die Gebrauchsanweisung der darunterliegenden Framework-API.
Öffentlich macht der Store nur:
isLoadingerrorvmreload()
Die Entity bleibt ein interner Eingang für das ViewModel-Mapping.
Die Presentation soll nicht entscheiden, ob sie die Entity oder das ViewModel verwendet. Für diesen Retrieve-Slice ist das ViewModel der öffentliche Read-Zugriff.
Der ACL gibt in diesem Beispiel kein null zurück.
Wenn die externe Antwort nicht in ein Article übersetzt werden kann, ist das kein leerer Artikel, sondern ein Fehler an der Systemgrenze. In einem produktiven System würde diese Stelle durch Runtime Validation abgesichert. Schlägt diese Validierung fehl, landet die Resource im Error-Zustand.
Der Store baut hier bewusst kein komplexes Page-State-Objekt.
Er stellt isLoading, error und vm getrennt bereit. Das ist für diesen Slice lesbarer: Der technische Lifecycle ist in der Infrastructure normalisiert, während das ViewModel eine einfache Projektion des optional vorhandenen Artikels bleibt.
Die Presentation bekommt diese Signale später über die Facade.
Der Grundlagenartikel Event-driven Projection beschreibt Zustand als Projektion. In diesem Slice ist withComputed der Ort, an dem diese Projektion sichtbar wird.
3. Application: bewusst dünn
Abschnitt betitelt „3. Application: bewusst dünn“In strengem DDD würde man hier einen Use Case erwarten.
Zum Beispiel:
LoadArticleUseCaseDer Use Case würde Daten laden, fachlich aufbereiten und ein Ergebnis zurückgeben.
In diesem Read-Slice wäre das aber oft künstlich.
Warum?
Weil der NgRx Signal Store mit withComputed bereits einen großen Teil der Komposition übernimmt:
- benannte Source Signals konsumieren
- Lade- und Fehlerzustand als Slice-API bereitstellen
- das ViewModel projizieren
- Reload als Slice-Intent anbieten
Ein zusätzlicher Application Use Case würde hier wahrscheinlich nur weiterreichen, was der Store bereits sauber modelliert.
Deshalb ist die Application-Schicht in diesem Beispiel bewusst dünn.
Nicht, weil Application unwichtig wäre.
Sondern weil dieser konkrete Read-Flow keinen schweren Use Case braucht.
article.facade.ts
Abschnitt betitelt „article.facade.ts“import { Injectable, inject } from '@angular/core';
import { ArticleStore } from '../+state/article.store';
@Injectable()export class ArticleFacade { private readonly store = inject(ArticleStore);
readonly vm = this.store.vm; readonly isLoading = this.store.isLoading; readonly error = this.store.error;
readonly reload = (): void => this.store.reload();}Diese Facade macht nicht viel.
Genau das ist der Punkt.
Sie definiert die öffentliche API der Page:
vm()isLoading()error()reload()Die Facade reicht hier nur die Signale und Operationen durch, die die Presentation wirklich braucht.
Sie reicht nicht die Entity durch.
Für diesen Retrieve-Slice ist das ViewModel der öffentliche Read-Zugriff. Die Entity bleibt ein interner Zwischenschritt zwischen Infrastructure und +state.
Die Presentation soll nicht wissen müssen, dass dahinter ein NgRx Signal Store, ein Infrastructure-Adapter und eine private httpResource stehen.
Sie kennt nur die Facade.
4. Presentation: Signale rendern, nicht orchestrieren
Abschnitt betitelt „4. Presentation: Signale rendern, nicht orchestrieren“Die Presentation ist jetzt langweilig.
Das ist gut.
Sie injiziert die Facade, stellt Provider für den Slice bereit und rendert das ViewModel.
article-page.component.ts
Abschnitt betitelt „article-page.component.ts“import { Component, inject } from '@angular/core';
import { ArticleStore } from '../+state/article.store';import { ArticleFacade } from '../application/article.facade';import { ArticleResource } from '../infrastructure/article.resource';
@Component({ selector: 'app-article-page', templateUrl: './article-page.component.html', providers: [ArticleResource, ArticleStore, ArticleFacade],})export class ArticlePageComponent { protected readonly facade = inject(ArticleFacade);}Der Provider-Schnitt ist hier bewusst lokal an der Page gezeigt.
Dieser Page-Slice bekommt seine eigene Resource, seinen eigenen Store und seine eigene Facade.
In einer echten Anwendung würde ich diese Provider häufig eher an die Route hängen. Für den Artikel ist die lokale Variante direkt an der Komponente aber hilfreich, weil der Slice dadurch sichtbar geschlossen bleibt.
Wichtig ist nicht, ob die Provider an der Route oder an der Page stehen.
Wichtig ist:
ArticleResourceArticleStoreArticleFacadebilden gemeinsam den Scope dieses Retrieve-Slice.
Die Komponente selbst bleibt dünn. Sie injiziert nur die Facade und rendert deren Signale im Template.
article-page.component.html
Abschnitt betitelt „article-page.component.html“@let vm = facade.vm();
<article class="article-page"> @if (facade.error()) { <p role="alert">Der Artikel konnte nicht geladen werden.</p>
<button type="button" (click)="facade.reload()"> Erneut versuchen </button> }
@if (facade.isLoading()) { <p class="article-page__loading"> Artikel wird aktualisiert … </p> }
@if (vm) { <header class="article-page__header"> <img class="article-page__image" [src]="vm.heroImageUrl" [alt]="vm.title" />
<h1>{{ vm.title }}</h1>
<p class="article-page__subtitle"> {{ vm.subtitle }} </p>
<div class="article-page__author"> <img class="article-page__author-avatar" [src]="vm.author.avatarUrl" [alt]="vm.author.name" />
<div> <p>{{ vm.author.name }}</p> <p>{{ vm.author.emailLabel }}</p> </div> </div> </header>
<div class="article-page__content"> @for (paragraph of vm.paragraphs; track paragraph) { <p>{{ paragraph }}</p> } </div> }</article>Das Template bindet das ViewModel mit @let.
Dadurch wird facade.vm() einmal als lokale Template-Variable sichtbar und der Markup-Teil bleibt ruhiger.
error und isLoading werden bewusst separat behandelt. Sie sind Resource-Zustände, keine Eigenschaften des ArticleVm.
isLoading räumt den Content nicht weg. Wenn bereits ein ViewModel vorhanden ist und ein Reload läuft, kann das Template einen kleinen Hinweis, Spinner oder Progress-Indikator anzeigen, während der Artikel sichtbar bleibt.
Das verhindert unnötiges Flackern.
Die Presentation-Logik kennt:
- kein DTO
- keine URL
- keine
HttpResourceRef - kein
hasValue() - kein
parse - kein
toDomain - kein
toViewModel - keine technische Ladezustandslogik
Sie bindet Facade-Signale und rendert.
Das ist kein Zufall.
Das ist der Schnitt.
Der komplette Datenfluss
Abschnitt betitelt „Der komplette Datenfluss“Noch einmal von unten nach oben:
1. Die API liefert ArticleDto2. infrastructure führt parseArticleResponse() aus3. infrastructure mappt DTO → Entity4. die private httpResource hält den technischen Resource-Lifecycle5. ArticleResource normalisiert ihn zu article, isLoading, error und reload6. +state projiziert Article | null → ArticleVm | null7. +state stellt isLoading, error, vm und reload als Slice-API bereit8. application reicht die öffentliche Page-API durch9. presentation rendert facade.vm()Das Entscheidende ist nicht, dass jede Datei besonders kompliziert wäre.
Im Gegenteil.
Die Dateien sind klein.
Aber jede Datei hat eine andere Frage.
article.resource.tsWelche externe Quelle wird geladen, und welche sichere Signal-API stellt die Infrastructure bereit?
article.mapper.tsWie wird externe Struktur in interne Sprache übersetzt?
article.store.tsWie werden Source Signals, Projektionen und Intents orchestriert?
article-view-model.mapper.tsWie sieht der Zustand für die UI aus?
article.facade.tsWas darf die Page konsumieren?
article-page.component.htmlWie wird das ViewModel dargestellt?Warum nicht alles im Store?
Abschnitt betitelt „Warum nicht alles im Store?“Man könnte den ganzen Slice in den Store schreiben.
HTTP-Zugriff, DTO-Mapping, Resource-Lifecycle, ViewModel-Mapping, Error-Zustand, Reload.
Das wäre weniger Dateien.
Aber weniger Dateien sind nicht automatisch weniger Komplexität.
Sie verstecken die Komplexität nur an einem Ort.
Der Store sollte in diesem Schnitt nicht zur neuen Gottklasse werden.
Er hat eine klare Aufgabe:
Er orchestriert benannte Quellen, Projektionen und Intents.
Die Infrastructure kapselt ihre technische Implementierung selbst.
Dadurch zeigt der Store beim Lesen den Read-Flow — nicht die Mechanik von httpResource.
Warum nicht alles in der Application?
Abschnitt betitelt „Warum nicht alles in der Application?“Man könnte auch argumentieren:
In DDD gehört der Use Case in die Application-Schicht. Also müsste die Application hier den Read-Flow bauen.
Das wäre theoretisch sauberer.
Aber in diesem konkreten Angular-Slice nicht zwingend besser.
Ein reiner Retrieve-Flow mit httpResource, Signals und NgRx Signal Store hat bereits eine starke reaktive Komposition. Wenn die Application-Schicht dann nur noch einen Use Case enthält, der ein Signal aus dem Store liest und wieder als Signal zurückgibt, entsteht keine Architektur.
Es entsteht Theater.
Deshalb ist die Application hier dünn.
Bei komplexeren Flows kann das anders aussehen:
- mehrere Stores koordinieren
- Berechtigungen prüfen
- Parameter aus Routing und User-Kontext kombinieren
- einen fachlichen Read Use Case bauen
- mehrere Ressourcen zu einer fachlichen Sicht zusammenführen
Dann verdient die Application-Schicht mehr Gewicht.
Dieser Artikel zeigt bewusst den kleinen Read-Slice.
Nicht den maximalen Architekturapparat.
Merksätze
Abschnitt betitelt „Merksätze“Retrieve ist mehr als GET und anzeigen.
httpResource gehört in diesem Schnitt zur Infrastructure, weil es externen Zugriff und einen technischen asynchronen Lifecycle modelliert.
Die konkrete HttpResourceRef bleibt privat in der Infrastructure.
parse ist hier die Transformationsgrenze. Runtime Validation kann später ergänzt werden, ist aber nicht der Kern dieses Slice.
Ein vorhandenes Domain Model muss gültig sein. Ein künstliches Empty Model ist bei einzelnen Entities kein Ersatz für sauber modellierte Abwesenheit.
Die Infrastructure stellt sichere, benannte Signals und explizite Operationen bereit.
Der Store interpretiert keinen technischen Resource-Lifecycle.
Er orchestriert Source Signals, ViewModel-Projektionen und Intents.
parse -> toDomain() ist ein anderer Schnitt als toViewModel().
Die Application-Schicht darf bei einfachen Read-Flows bewusst dünn bleiben.
Die Presentation rendert Signale, statt Lade-, Mapping- und Infrastruktur-Logik zu orchestrieren.