Zum Inhalt springen

Create Slice: Schreiben als Command Flow

Daten zu laden ist ein Read-Problem.

Daten zu erzeugen ist ein Command-Problem.

Das klingt wie eine kleine Unterscheidung.

Ist es aber nicht.

Ein Retrieve-Slice beantwortet die Frage:

Was soll die UI anzeigen?

Ein Create-Slice beantwortet eine andere Frage:

Welche fachliche Absicht schickt der Benutzer ins System?

Deshalb möchte ich Create in diesem Artikel nicht als „POST aus der Komponente“ zeigen.

Und auch nicht als „wir erweitern einfach denselben Store um ein paar Methoden“.

Es gibt viele Wege, Create-Flows zu modellieren:

  • Service direkt aus der Facade aufrufen
  • Store-Methode mit create()
  • Command Store
  • klassische Effects
  • Optimistic Updates
  • Form-State mit Submit-Status
  • Event-getriebene Projektionen

Auf einige dieser Varianten werde ich später eingehen.

In diesem Artikel geht es bewusst um einen event-driven Create-Slice.

Der Submit speichert nicht direkt.

Der Submit sendet eine Absicht.


Create ist kein POST aus der Komponente. Create ist Intent → Command → Result → Reaktion.

Der wichtigste Punkt:

Create ist kein Retrieve mit anderer HTTP-Methode.

Bei Retrieve lesen wir externe Daten und bauen daraus ein ViewModel.

Bei Create senden wir eine Absicht ins System.

Diese Absicht kann erfolgreich sein.

Sie kann scheitern.

Und auf beide Ergebnisse können mehrere unabhängige Reaktionen folgen.

submit
→ createRequested
→ POST
→ createSucceeded | createFailed
→ unabhängige Reaktionen

Das ist ein anderer Schnitt als:

submit
→ http.post()
→ listStore.reload()
→ router.navigate()
→ toast.show()

Im direkten Ablauf kennt jeder Schritt zu viel vom nächsten.

Im event-driven Slice sind Absicht, Ausführung und Reaktion getrennt.


Die naheliegende Lösung ist oft ein großer Store.

Er lädt die Liste.

Er hält das ausgewählte Element.

Er kennt den Formularzustand.

Er validiert Eingaben.

Er schickt den POST.

Er behandelt Fehler.

Er aktualisiert die Liste.

Er navigiert vielleicht noch zurück.

Und irgendwann heißt dieser Store zwar noch ArticleStore, ist aber eigentlich ein kleiner Gott.

Er weiß alles.

Er kann alles.

Er wird überall gebraucht.

Das ist bequem am Anfang.

Aber es vermischt zwei sehr unterschiedliche Modelle:

  • Read Model: Was zeigt die UI?
  • Command Model: Welche Absicht wird ausgeführt?

Diese Trennung ist nicht akademisch.

Read Models dürfen bequem, denormalisiert und UI-nah sein.

Commands sollten klein, explizit und absichtsorientiert sein.

Wenn beides im selben Store landet, wächst der Store schnell in zwei Richtungen gleichzeitig: Er wird Projektionsmodell und Kommandozentrale.

Genau deshalb trenne ich in diesem Slice:

ArticleResource
→ kapselt private httpResource
→ stellt article, isLoading, error und reload bereit
Read Store
→ orchestriert Source Signals
→ berechnet ViewModel
Command Store
→ hört auf Command Events
→ führt Writes aus
→ feuert Success/Error Events

Der Read Store ist nicht der Ort, an dem Commands „irgendwie auch noch“ abgearbeitet werden.

Der Command Store ist nicht der Ort, an dem ViewModels entstehen.


Hier entsteht allerdings eine Reibung mit NgRx Signal Store.

Architektonisch möchte ich Read Model und Command Model trennen.

Praktisch ist NgRx Signal Store aber DI-getrieben: Ein Store existiert erst dann im relevanten Scope, wenn er bereitgestellt und injiziert wird.

Das ist für Read Models sehr angenehm.

Eine Page braucht Daten, also injiziert sie ihre Facade oder ihren Store.

Für einen separaten Command Store ist es weniger elegant.

Denn ein Command Store wird häufig gar nicht direkt gerendert. Er soll eine Absicht entgegennehmen, eine Nebenwirkung ausführen und danach Events erzeugen.

Trotzdem muss er existieren.

Stand NgRx Signal Store 21 bedeutet das: Wenn ich Read Store und Command Store trenne, muss ich den Command Store bewusst einmal im Slice-Scope instanziieren.

Zum Beispiel:

  • über die Provider der Page
  • über die Provider der Route
  • über eine Facade, die den Command Store injiziert
  • über einen kleinen Bridge-Service

Das ist nicht dramatisch.

Aber es ist wichtig, es nicht zu verschweigen.

Die saubere Architektur kostet hier eine kleine mechanische Entscheidung.

NgRx beschreibt das Events-Plugin als event-basierte State-Management-Schicht für SignalStore. In NgRx 21 wurde außerdem withEffects im Events-Plugin zu withEventHandlers umbenannt. Das passt gut zu diesem Slice, weil wir Writes nicht als direkte Store-Methode modellieren, sondern als Ereignisfluss.


Im Retrieve-Artikel lag httpResource in der Infrastructure, weil es HTTP- und API-Semantik trägt.

Für Create ist der Punkt noch schärfer.

Angulars Resource-API ist für asynchrone Leseabhängigkeiten gedacht. Die Angular-Dokumentation weist explizit darauf hin, dass resource für Read-Operationen gedacht ist und nicht für Mutationen, weil laufende Loads bei Änderungen oder Destroy abgebrochen werden können. httpResource ist außerdem ein Wrapper um HttpClient, der Request-Status und Response als Signals bereitstellt und durch den Angular-HTTP-Stack inklusive Interceptors läuft.

Deshalb verwende ich für Create bewusst keinen resource()-Write.

Ein Write ist kein abgeleiteter Lesezustand.

Ein Write ist ein Command.

Retrieve:
resource lädt Daten
Create:
command führt Absicht aus

Für diesen Artikel heißt das:

  • Read-Seite: private httpResource innerhalb von ArticleResource
  • Write-Seite: HttpClient in ArticleCommand
  • Verbindung: Events

Die konkrete HttpResourceRef verlässt die Infrastructure nicht.

Auch hasValue() und der abgesicherte Zugriff auf value() bleiben dort.

Das sind keine fachlichen Entscheidungen des Read Stores. Es ist technische Lifecycle-Semantik der Angular Resource.

Der Read Store konsumiert danach nur noch klar benannte Signals und eine explizite reload()-Operation.


Wir bleiben bei der Lorem API.

Der Retrieve-Artikel hatte gelesen:

GET https://lorem-api.com/api/article/foo

Dieser Create-Slice schreibt an eine Create-URL:

POST https://lorem-api.com/api/article

Ob diese API in einem echten System exakt so antwortet, ist für den Slice nicht entscheidend.

Entscheidend ist der Schnitt:

Form
→ CreateArticleCommand
→ ArticleCommand.createArticle(command)
→ articleCreateEvents.createSucceeded | createFailed
→ articleReadEvents.loadRequested
→ Read Store stößt ArticleResource.reload() an

Eine mögliche Struktur für den Slice:

article/
├── entities/
│ └── article.model.ts
├── infrastructure/
│ ├── article.dto.ts
│ ├── article.mapper.ts
│ ├── article.resource.ts
│ ├── article-create.dto.ts
│ ├── article-create.mapper.ts
│ └── article.command.ts
├── +state/
│ ├── article.store.ts
│ ├── article.vm.ts
│ ├── article-view-model.mapper.ts
│ ├── create-article.command.ts
│ ├── article-create.events.ts
│ ├── article-read.events.ts
│ └── article-command.store.ts
├── application/
│ ├── article.facade.ts
│ └── article-create.facade.ts
└── presentation/
├── article-create-page.component.ts
└── article-create-page.component.html

Das wirkt auf den ersten Blick mehr als beim Retrieve-Slice.

Aber die Dateien haben klare Aufgaben:

create-article.command.ts
→ fachliche Absicht
article-create.events.ts
→ requested / succeeded / failed
article.command.ts
→ Write-Operationen gegen die externe API
article-command.store.ts
→ Event Handler für Write Flow
article-read.events.ts
→ Read-Seite kann Reload anfordern
article.resource.ts
→ kapselt private httpResource und stellt sichere Source Signals bereit
article.store.ts
→ orchestriert Source Signals und reagiert auf loadRequested

Die zusätzliche Struktur verhindert, dass ein einzelner Store alles wissen muss.


Ein Command ist kein DTO.

Ein Command ist kein ViewModel.

Ein Command ist auch kein Formularmodell.

Ein Command beschreibt die Absicht, die aus der UI kommt.

export interface CreateArticleCommand {
readonly title: string;
readonly subtitle: string;
readonly content: string;
}

Mehr braucht dieser Artikel nicht.

Keine Validierung.

Keine Metadaten.

Keine UI-Flags.

Das Command-Modell bleibt absichtlich klein.

CreateArticleCommand
= Benutzer möchte einen Artikel erstellen

Das DTO entsteht später an der API-Grenze.

Nicht hier.


Jetzt definieren wir die Events des Create-Slice.

import { eventGroup, type } from '@ngrx/signals/events';
import { CreateArticleCommand } from './create-article.command';
export const articleCreateEvents = eventGroup({
source: 'Article Create',
events: {
createRequested: type<CreateArticleCommand>(),
createSucceeded: type<{ readonly articleId: string }>(),
createFailed: type<{ readonly error: unknown }>(),
},
});

Der wichtige Teil ist nicht die Syntax.

Der wichtige Teil ist die Sprache:

createRequested
createSucceeded
createFailed

createRequested ist noch kein Ergebnis.

Es ist nur eine Absicht.

createSucceeded und createFailed sind Ergebnisse der Ausführung.

Diese Trennung ist wertvoll.

Denn Reaktionen sollten nicht am Submit hängen.

Sie sollten am Ergebnis hängen.


Der Command Store soll den Read Store nicht direkt kennen.

Er soll nicht sagen:

articleStore.reload()

Das wäre wieder direkte Kopplung.

Stattdessen gibt es ein Read Event:

import { eventGroup, type } from '@ngrx/signals/events';
export const articleReadEvents = eventGroup({
source: 'Article Read',
events: {
loadRequested: type<void>(),
},
});

Das klingt vielleicht nach einer kleinen Indirektion.

Aber genau diese Indirektion ist der Schnitt:

createSucceeded
→ loadRequested
→ Read Store stößt ArticleResource.reload() an

Die Write-Seite sagt nicht, wie die Read-Seite lädt.

Sie sagt nur:

Nach diesem erfolgreichen Write sollten die gelesenen Artikeldaten neu angefordert werden.


Bis zur Infrastructure arbeiten wir mit dem Command.

Erst an der API-Grenze entsteht das DTO.

export interface CreateArticleDto {
readonly title: string;
readonly subtitle: string;
readonly content: string;
}
export interface CreateArticleResponseDto {
readonly id: string;
}

Das DTO beschreibt den externen Vertrag.

Nicht die interne Sprache.

import { CreateArticleCommand } from '../+state/create-article.command';
import { CreateArticleDto } from './article-create.dto';
export const toCreateArticleDto = ({ title, subtitle, content }: CreateArticleCommand): CreateArticleDto => ({
title,
subtitle,
content,
});

Das Mapping ist hier langweilig.

Das ist gut.

Es zeigt trotzdem den richtigen Ort.

Wenn die externe API später andere Feldnamen, zusätzliche Wrapper oder technische Metadaten braucht, passiert das an dieser Grenze.

Nicht in der Komponente.

Nicht in der Facade.

Nicht im Command.

import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { CreateArticleCommand } from '../+state/create-article.command';
import { CreateArticleResponseDto } from './article-create.dto';
import { toCreateArticleDto } from './article-create.mapper';
@Injectable()
export class ArticleCommand {
private readonly http = inject(HttpClient);
readonly createArticle = (command: CreateArticleCommand) => this.http.post<CreateArticleResponseDto>('https://lorem-api.com/api/article', toCreateArticleDto(command));
}

In der Infrastructure liegt in diesem Slice eine Datei article.command.ts.

Sie bündelt die Write-Operationen, die der Command Store ausführen kann.

Das ist bewusst 1:1 geschnitten:

ArticleCommandStore
→ nutzt ArticleCommand

Der Store entscheidet, wann eine Write-Operation ausgeführt wird.

Die Infrastructure entscheidet, wie dieser Write gegen die externe API übersetzt wird.

Das Payload-Modell bleibt im State-Slice:

CreateArticleCommand
→ fachliche Absicht

Das DTO entsteht erst an der API-Grenze:

CreateArticleCommand
→ toCreateArticleDto()
→ POST

5. Command Store: requested ausführen, Ergebnis feuern

Abschnitt betitelt „5. Command Store: requested ausführen, Ergebnis feuern“

Jetzt kommt der Command Store.

Er hält kein ViewModel.

Er rendert nichts.

Er hört auf Events, führt die Infrastructure aus und veröffentlicht Ergebnis-Events.

import { inject } from '@angular/core';
import { mapResponse } from '@ngrx/operators';
import { signalStore, withProps } from '@ngrx/signals';
import { Events, withEventHandlers } from '@ngrx/signals/events';
import { exhaustMap, map } from 'rxjs';
import { ArticleCommand } from '../infrastructure/article.command';
import { articleCreateEvents } from './article-create.events';
import { articleReadEvents } from './article-read.events';
export const ArticleCommandStore = signalStore(
withProps(() => ({
_articleCommand: inject(ArticleCommand),
})),
withEventHandlers(({ _articleCommand }, events = inject(Events)) => ({
createArticle$: events.on(articleCreateEvents.createRequested).pipe(
exhaustMap((command) =>
_articleCommand.createArticle(command).pipe(
mapResponse({
next: (response) =>
articleCreateEvents.createSucceeded({
articleId: response.id,
}),
error: (error: unknown) => articleCreateEvents.createFailed({ error }),
}),
),
),
),
reloadOnCreateSucceeded$: events.on(articleCreateEvents.createSucceeded).pipe(map(() => articleReadEvents.loadRequested())),
// notifyOnCreateSucceeded$: events
// .on(articleCreateEvents.createSucceeded)
// .pipe(
// map(() =>
// notificationEvents.showSuccess({
// summary: {
// key: 'articles.notifications.create.success.summary',
// },
// detail: {
// key: 'articles.notifications.create.success.detail',
// },
// }),
// ),
// ),
// navigateOnCreateSucceeded$: events
// .on(articleCreateEvents.createSucceeded)
// .pipe(
// map(() => articleNavigationIntentEvents.openList()),
// ),
})),
);

Der Store macht drei Dinge:

1. createRequested entgegennehmen
2. POST über Infrastructure ausführen
3. createSucceeded oder createFailed veröffentlichen

Danach übersetzt er createSucceeded in ein Read Event:

createSucceeded
→ loadRequested

Das ist absichtlich kein direkter Aufruf auf den Read Store.

Der Command Store kennt nicht die Resource.

Er kennt nicht das ViewModel.

Er kennt nicht die Presentation.

Er kennt nur Events.

Die zusätzlichen Success-Reaktionen sind im Code bewusst nur als zukünftige Events angedeutet.

Auch Navigation und Notification müssen nicht als direkte imperative Seiteneffekte im Command Store passieren.

Stattdessen können sie wieder als Events beschrieben werden:

createSucceeded
→ notificationEvents.showSuccess(...)
createSucceeded
→ articleNavigationIntentEvents.openList()

Der Command Store kennt dann weder Toast-Service noch Router.

Er beschreibt nur, was nach einem erfolgreichen Create fachlich oder UI-seitig passieren soll.

Andere Teile des Slice oder der Anwendung können diese Events aufnehmen und daraus echte Side Effects machen.

tap bleibt damit den Stellen vorbehalten, an denen wirklich eine imperative Grenze erreicht wird: Router, Toast-Service, Logging oder externe APIs.

Solange eine Reaktion aber wieder als Event beschrieben werden kann, ist map die sauberere Wahl.


Bei Create-Flows ist Doppel-Submit ein reales Problem.

Der Benutzer klickt zweimal.

Das Formular submitet zweimal.

Der Browser ist langsam.

Das Netzwerk hängt.

Deshalb ist die Wahl des Flattening-Operators keine Kleinigkeit.

Für diesen Artikel nutze ich exhaustMap.

exhaustMap
→ ignoriert weitere Submits, solange ein Create läuft
concatMap
→ queued mehrere Creates nacheinander
mergeMap
→ erlaubt parallele Creates
switchMap
→ bricht alte Creates ab

Für einen Create-Command ist switchMap oft gefährlich.

Ein Write ist keine Suchanfrage.

Wenn ein Write einmal unterwegs ist, will ich ihn normalerweise nicht still abbrechen, nur weil ein zweiter Submit kommt.

exhaustMap ist für diesen einfachen Slice die konservative Wahl.


Der Retrieve-Artikel hatte den Read Store bereits eingeführt.

Für Create ergänzen wir nur einen Event Handler.

Die konkrete Angular Resource bleibt vollständig in der Infrastructure.

ArticleResource hält die httpResource privat und stellt nach außen nur eine kleine, benannte API bereit:

article: Signal<Article | null>
isLoading: Signal<boolean>
error: Signal<Error | undefined>
reload(): void

Damit bleiben auch die technischen Guards an der richtigen Stelle.

hasValue() schützt den Zugriff auf value().

Das ist keine fachliche Fallentscheidung des Stores.

Es ist Framework-Semantik der Angular Resource und gehört deshalb in den Infrastructure-Adapter, der diese Resource besitzt.

Der Read Store kennt danach weder:

  • HttpResourceRef
  • hasValue()
  • das Wurfverhalten von value()
  • ResourceStatus
  • Parse- oder Request-Details

Er orchestriert nur Source Signals, ViewModel-Projektion und Read-Intents.

import { computed, inject } from '@angular/core';
import { signalStore, withComputed, withProps } from '@ngrx/signals';
import { Events, withEventHandlers } from '@ngrx/signals/events';
import { tap } from 'rxjs';
import { ArticleResource } from '../infrastructure/article.resource';
import { articleReadEvents } from './article-read.events';
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())),
})),
withEventHandlers(({ _articleResource }, events = inject(Events)) => ({
reloadOnLoadRequested$: events.on(articleReadEvents.loadRequested).pipe(tap(() => _articleResource.reload())),
})),
);

Der ViewModel-Mapper bildet dabei:

Article | null
→ ArticleVm | null

Die Domain Entity selbst bleibt strikt.

Es gibt keinen künstlichen leeren Artikel, nur damit der Store ohne Condition auskommt.

Ein vorhandenes Article muss gültig sein.

Die Abwesenheit eines lesbaren Werts wird als null an der Infrastructure-Grenze normalisiert.

Der Store projiziert diese Source nur weiter.

Das ist der zentrale Punkt des Reload-Handlings.

Nicht so:

Command Store
→ articleStore.reload()

Sondern so:

Command Store
→ articleReadEvents.loadRequested()
Read Store
→ events.on(loadRequested)
→ ArticleResource.reload()

Die Write-Seite stößt nur einen Read-Intent an.

Die Read-Seite entscheidet, welche Source-Operation darauf folgt.

Dabei interpretiert sie aber keinen technischen Resource-Lifecycle.

Sie orchestriert nur:

loadRequested
→ ArticleResource.reload()
article
→ ArticleVm

Hier ist tap in Ordnung.

Nicht, weil der Store selbst HTTP- oder Resource-Logik ausführt.

Sondern weil der Event-Flow an dieser Stelle eine explizite Operation seiner Infrastructure-Abhängigkeit anstößt.

Davor werden Events auf Events abgebildet.

Hier wird die Read-Operation tatsächlich ausgelöst.

Das hält den Lesefluss im Store ruhig.

Beim Lesen interessiert:

  • welche Source konsumiert wird
  • welches ViewModel entsteht
  • welcher Intent welche Operation anstößt

Nicht die Gebrauchsanweisung von httpResource.

Wenn später mehrere Read Stores auf denselben Write-Erfolg reagieren müssen, müssen sie nicht alle im Command Store bekannt sein.

Sie können jeweils auf Events hören.


Die Create-Facade führt keinen POST aus.

Sie kennt keinen HttpClient.

Sie ruft auch den Command Store nicht direkt auf.

Sie veröffentlicht nur die Absicht.

import { Injectable, inject } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import { articleCreateEvents } from '../+state/article-create.events';
import { CreateArticleCommand } from '../+state/create-article.command';
@Injectable()
export class ArticleCreateFacade {
private readonly dispatchCreate = injectDispatch(articleCreateEvents);
readonly createArticle = (command: CreateArticleCommand): void => {
this.dispatchCreate.createRequested(command);
};
}

Die Facade nutzt injectDispatch(articleCreateEvents).

Damit bekommt sie eine kleine, typisierte Dispatch-API für genau diese Event-Gruppe.

Sie muss keinen generischen Dispatcher kennen und kein Event manuell zusammenbauen.

Die beiden Stores werden im Konstruktor bewusst nur als Lifecycle-Abhängigkeiten injiziert.

Sie sorgen dafür, dass die Event Handler des Read Stores und des Command Stores im Slice-Scope existieren.

Die Facade ruft keine Store-Methode auf.

Das ist die öffentliche API der Create-Page:

createArticle(command)

Die Facade erstellt keinen Artikel.

Sie veröffentlicht die Absicht, einen Artikel zu erstellen.

Das ist eine kleine sprachliche Verschiebung mit großer Wirkung.

Denn jetzt hängt der Submit nicht mehr direkt an den Folgeaktionen.

Presentation
→ facade.createArticle(command)
→ articleCreateEvents.createRequested(command)

Was danach passiert, gehört nicht mehr der Presentation.


Für die Eingabe nutze ich Signal Forms.

Aber bewusst dünn.

Signal Forms sind hier nicht die Architektur.

Sie sind nur die Eingabeschicht.

Angular beschreibt Signal Forms als signalbasierten Ansatz, um Form-State in Angular-Anwendungen zu verwalten. Für diesen Artikel nutze ich davon nur den einfachen Teil: ein kleines Modell, ein Form Binding und einen Submit. Validierung, Submit-Status, Schema-Validation und komplexe Form-Interaktionen bleiben bewusst außen vor.

import { Component, inject, signal } from '@angular/core';
import { form } from '@angular/forms/signals';
import { ArticleCommandStore } from '../+state/article-command.store';
import { ArticleStore } from '../+state/article.store';
import { ArticleCreateFacade } from '../application/article-create.facade';
import { ArticleCommand } from '../infrastructure/article.command';
import { ArticleResource } from '../infrastructure/article.resource';
@Component({
selector: 'app-article-create-page',
templateUrl: './article-create-page.component.html',
providers: [ArticleResource, ArticleCommand, ArticleStore, ArticleCommandStore, ArticleCreateFacade],
})
export class ArticleCreatePageComponent {
protected readonly facade = inject(ArticleCreateFacade);
protected readonly model = signal({
title: '',
subtitle: '',
content: '',
});
protected readonly articleForm = form(this.model);
}

Der Provider-Schnitt ist hier bewusst lokal an der Page gezeigt.

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 aber hilfreich, weil der Slice dadurch sichtbar geschlossen bleibt.

Wichtig ist:

ArticleResource
ArticleCommand
ArticleStore
ArticleCommandStore
ArticleCreateFacade

bilden gemeinsam den Scope dieses Create-Slice.

ArticleResource kapselt dabei die private httpResource.

ArticleStore konsumiert ihre Source Signals und orchestriert den Read-Flow.

Der ArticleCommandStore ist hier bewusst Teil dieses Scopes.

Allerdings ist das nur die Scope-Entscheidung.

In einer echten Anwendung muss der Command Store auch tatsächlich einmal im Lifecycle dieses Slice instanziiert werden, damit seine Event Handler aktiv sind.

Das ist eine kleine mechanische Reibung dieses Ansatzes.

Je nach Projekt kann das zum Beispiel über die Route, einen bewusst injizierten Slice-Service oder eine andere Lifecycle-Stelle passieren.

Ich zeige hier bewusst keinen versteckten Constructor-Hack wie inject(ArticleCommandStore) nur zur Instanziierung.

Das würde den Code zwar möglicherweise zum Laufen bringen, aber die Abhängigkeit verschleiern.

Für den Artikel ist wichtiger:

Command Store existiert im Slice-Scope → hört auf createRequested → feuert createSucceeded / createFailed

Wie genau dieser Lifecycle im Projekt verdrahtet wird, ist eine Framework-Mechanik.

Der fachliche Schnitt bleibt davon unberührt.


<form class="article-create" (ngSubmit)="facade.createArticle(model())">
<label>
Titel
<input [field]="articleForm.title" />
</label>
<label>
Untertitel
<input [field]="articleForm.subtitle" />
</label>
<label>
Inhalt
<textarea [field]="articleForm.content"></textarea>
</label>
<button type="submit">Artikel erstellen</button>
</form>

Das Template ist absichtlich unspektakulär.

Kein HTTP.

Kein Store.

Kein Event Dispatch.

Keine Success-Logik.

Keine Reload-Logik.

Keine Navigation.

Die Presentation sammelt Formulardaten und ruft die Facade über ngSubmit auf.

Mehr nicht.

Form ausfüllen
→ submit()
→ facade.createArticle(command)

Das ist der ganze Job der Komponente.


Der eigentliche Vorteil des event-driven Slice zeigt sich nach dem Write.

Ein erfolgreicher Create kann mehrere Dinge auslösen:

createSucceeded
→ request read reload
→ navigate
→ show toast
→ close dialog
→ update badge count

In einem direkten Flow landet das schnell alles hinter dem Submit.

Dann sieht der Ablauf irgendwann so aus:

submit()
→ post()
→ reloadList()
→ navigate()
→ toast()
→ closeDialog()

Das ist kein Create-Slice mehr.

Das ist ein Orchestrierungs-Knoten in der UI.

Im event-driven Flow hängen diese Reaktionen am Ergebnis:

createSucceeded
→ loadRequested
createSucceeded
→ notificationEvents.showSuccess(...)
createSucceeded
→ articleNavigationIntentEvents.openList()

Für diesen Artikel implementieren wir nur den Reload.

Navigation und Notification sind bewusst nur angedeutet.

Das hält den Slice klein.

Der Artikel zeigt den Schnitt, nicht jede mögliche Reaktion.


Auch Fehler sollten nicht einfach als lokales catchError in der Komponente enden.

Ein Fehler ist ebenfalls ein Ergebnis des Commands.

createRequested
→ POST
→ createFailed(error)

Was danach passiert, ist wieder eine Reaktion:

createFailed
→ // show error toast
createFailed
→ // keep dialog open
createFailed
→ // mark form as failed

Für diesen Artikel reicht das Event.

Eine echte UI könnte später auf createFailed reagieren und einen Toast anzeigen oder den Dialog offen lassen.

Wichtig ist:

Der Command Store entscheidet nicht, wie der Fehler angezeigt wird.

Er veröffentlicht nur:

Der Create ist fehlgeschlagen.


Für ein einzelnes Formular kann dieser Schnitt nach mehr Code aussehen.

Das stimmt.

Ein direkter Service-Aufruf ist kürzer.

Aber kürzer ist nicht automatisch einfacher.

Ein direkter Service-Aufruf ist oft nur deshalb kurz, weil er Kopplung versteckt.

Component kennt Submit
Component kennt HTTP
Component kennt Reload
Component kennt Navigation
Component kennt Toast
Component kennt Fehlerbehandlung

Der event-driven Slice macht diese Übergänge sichtbar.

Intent
→ Command
→ Result
→ Reaction

Das ist nicht für jedes Mini-Formular nötig.

Aber es ist wertvoll, sobald ein Write mehr als eine lokale Folge hat.

Also zum Beispiel:

  • Liste neu laden
  • Detailseite aktualisieren
  • Navigation auslösen
  • Toast anzeigen
  • Dialog schließen
  • Cache invalidieren
  • mehrere Read Models aktualisieren

Dann ist ein Event kein akademischer Umweg.

Es ist eine Entkopplung.


Der Retrieve-Slice hatte diese Richtung:

API DTO
→ private httpResource
→ sichere Source Signals
→ Entity | null
→ ViewModel | null
→ Template

Der Create-Slice hat eine andere Richtung:

Form
→ Command
→ DTO
→ POST
→ Event
→ Reaktion

Das ist der Kern.

Read und Write sind nicht symmetrisch.

Ein Read baut ein Modell für Anzeige.

Ein Write sendet eine Absicht ins System.

Deshalb sollten sie im Frontend auch nicht automatisch im selben Store landen.


Ein Create ist kein POST aus der Komponente.

Ein Create ist ein Intent.

Die Komponente sammelt Eingaben.

Die Facade veröffentlicht eine Absicht.

Der Command Store führt diese Absicht aus.

Die Infrastructure spricht mit der API.

Success und Error werden als Events sichtbar.

Read Models reagieren über eigene Events.

submit
→ createRequested(command)
→ ArticleCommand.createArticle(command)
→ createSucceeded | createFailed
→ loadRequested
→ Read Store stößt ArticleResource.reload() an

Der wichtigste Punkt ist nicht der zusätzliche Code.

Der wichtigste Punkt ist die Richtung:

UI sendet Absicht.
Command Store führt aus.
Events beschreiben Ergebnisse.
Reaktionen bleiben unabhängig.

So bleibt der Read Store ein Read Store.

Die Infrastructure kapselt ihre technische Resource selbst.

Der Read Store orchestriert nur Source Signals, Projektionen und Read-Intents.

Der Command Store bleibt ein Command Store.

Und aus einem harmlosen ArticleStore wird kein gottgleiches Objekt, das irgendwann das halbe Feature kontrolliert.