Zum Inhalt springen

Update Slice: Formularzustand ist kein DTO

Daten zu ändern ist ein Command.

Nicht, weil ein PUT oder PATCH technisch kompliziert wäre.

Sondern weil ein Update eine bestehende fachliche Sache verändert.

Ein Create sagt:

Lege etwas Neues an.

Ein Delete sagt:

Entferne etwas Bestehendes.

Ein Update sagt:

Ändere etwas Bestehendes in einen neuen Zustand.

Das klingt nah an Create.

Und technisch ist der Flow tatsächlich sehr ähnlich.

Aber auch hier gilt:

Ein Update sollte nicht als schneller Formular-Submit enden:

submit
→ http.put()
→ reload()
→ toast()
→ navigate()

Das ist kurz.

Aber die Komponente kennt dann zu viel:

  • sie kennt HTTP
  • sie kennt die Update-Operation
  • sie kennt Reload
  • sie kennt Notification
  • sie kennt Navigation
  • sie kennt Fehlerbehandlung

In diesem Artikel schneide ich Update deshalb wie einen Command-Flow:

update intent
→ facade.updateArticle(command)
→ articleUpdateEvents.updateRequested(command)
→ ArticleCommandStore
→ ArticleCommand.updateArticle(command)
→ updateSucceeded | updateFailed
→ reload / notification / navigation

Der Submit aktualisiert nicht direkt.

Der Submit sendet eine Änderungsabsicht.


Der Submit speichert nicht direkt. Der Submit sendet eine Änderungsabsicht.

Der wichtigste Punkt:

Update ist kein Formular mit http.put() dahinter.

Update ist eine Absicht.

Diese Absicht kann erfolgreich sein.

Sie kann scheitern.

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

updateRequested
→ PUT
→ updateSucceeded | updateFailed
→ unabhängige Reaktionen

Das ist ein anderer Schnitt als:

submit
→ http.put()
→ reloadList()
→ toast()
→ navigate()

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

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


Was dieser Artikel bewusst nicht zeigt: Form-Komplexität

Abschnitt betitelt „Was dieser Artikel bewusst nicht zeigt: Form-Komplexität“

Update-Flows können schnell groß werden.

Man kann über vieles sprechen:

  • Initialwerte aus dem Read Model
  • Dirty State
  • Validierung
  • Partial Update
  • Full Update
  • Optimistic Update
  • Konflikte bei paralleler Bearbeitung
  • ETags oder Versionsnummern
  • Save-on-blur
  • Auto-Save

Das sind wichtige Themen.

Aber sie sind nicht der Fokus dieses Artikels.

Hier geht es nur um den Slice nach der Entscheidung:

Der Benutzer hat eine Änderung abgeschickt.

Ab diesem Moment wird aus Form-State ein Command.

Form submit
→ UpdateArticleCommand
→ updateRequested

Die Form bleibt in diesem Artikel bewusst dünn.

Keine Validierung.

Kein Dirty Tracking.

Kein Conflict Handling.

Nur der Update-Flow.


Die Versuchung ist groß.

Der Read Store kennt den Artikel sowieso.

Also bekommt er schnell noch eine Methode:

updateArticle(command: UpdateArticleCommand): void {
this.http.put(`/api/article/${command.articleId}`, command).subscribe(() => {
this.reload();
});
}

Das funktioniert.

Aber der Store verändert seine Rolle.

Er ist nicht mehr nur Read Model.

Er ist plötzlich auch Command Handler.

Er lädt Daten.

Er hält ViewModels.

Er aktualisiert Daten.

Er behandelt Fehler.

Er triggert Reload.

Vielleicht zeigt er später noch Toasts oder navigiert zurück.

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

Genau das will dieser Slice vermeiden.

ArticleResource
→ kapselt private httpResource
→ stellt article, isLoading, error und reload bereit
Read Store
→ orchestriert Source Signals
→ berechnet ViewModel
→ reagiert auf loadRequested
Command Store
→ hört auf updateRequested
→ führt PUT aus
→ feuert updateSucceeded / updateFailed
→ leitet Success-Reaktionen als Events weiter

Der Read Store bleibt ein Read Store.

Der Command Store bleibt ein Command Store.


Eine mögliche Struktur für diesen Slice:

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

Wenn Create, Update und Delete im selben Feature leben, teilen sie sich häufig dieselbe Command-Infrastructure:

+state/
├── create-article.command.ts
├── update-article.command.ts
├── delete-article.command.ts
├── article-create.events.ts
├── article-update.events.ts
├── article-delete.events.ts
└── article-command.store.ts
infrastructure/
└── article.command.ts

Das Pattern bleibt gleich:

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.

Auf der Read-Seite gilt dieselbe Grenze wie im Retrieve-Slice:

private httpResource
→ ArticleResource
→ sichere Source Signals
→ ArticleStore
→ ViewModel

Die konkrete HttpResourceRef bleibt in der Infrastructure.

Der Read Store kennt weder hasValue() noch das Wurfverhalten von value().


Ein Update-Command braucht die ID des bestehenden Artikels und die neuen Werte.

export interface UpdateArticleCommand {
readonly articleId: string;
readonly title: string;
readonly subtitle: string;
readonly content: string;
}

Das ist kein DTO.

Das ist kein ViewModel.

Das ist kein Formularmodell.

Das ist die fachliche Absicht:

UpdateArticleCommand
= Benutzer möchte diesen Artikel ändern

Der Command enthält articleId, weil Update auf ein bestehendes Objekt zielt.

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

Nicht im Formular.

Nicht in der Facade.

Nicht im Read Store.


Jetzt definieren wir die Events des Update-Slice.

import { eventGroup, type } from '@ngrx/signals/events';
import { UpdateArticleCommand } from './update-article.command';
export const articleUpdateEvents = eventGroup({
source: 'Article Update',
events: {
updateRequested: type<UpdateArticleCommand>(),
updateSucceeded: type<{ readonly articleId: string }>(),
updateFailed: type<{
readonly articleId: string;
readonly error: unknown;
}>(),
},
});

Die Sprache ist wieder wichtiger als die Syntax:

updateRequested
updateSucceeded
updateFailed

updateRequested ist noch kein Ergebnis.

Es ist eine Absicht.

updateSucceeded und updateFailed sind Ergebnisse der Ausführung.

Ich nehme die articleId auch in updateFailed mit auf.

Warum?

Weil ein Fehler ohne Kontext wenig wert ist.

Gerade bei mehreren bearbeitbaren Artikeln möchte man wissen:

Welches Update ist fehlgeschlagen?

Das macht Fehlerreaktionen testbarer und nachvollziehbarer.


Nach einem erfolgreichen Update sollen die gelesenen Daten aktualisiert werden.

Aber der Command Store soll den Read Store nicht direkt kennen.

Nicht so:

updateSucceeded
→ articleStore.reload()

Sondern so:

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

Dafür gibt es ein Read Event:

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

Das wirkt wie eine kleine Indirektion.

Aber genau diese Indirektion ist der Schnitt.

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 UpdateArticleDto {
readonly title: string;
readonly subtitle: string;
readonly content: string;
}
export interface UpdateArticleResponseDto {
readonly id: string;
}

Das DTO beschreibt den externen Vertrag.

Nicht die interne Sprache.

import { UpdateArticleCommand } from '../+state/update-article.command';
import { UpdateArticleDto } from './article-update.dto';
export const toUpdateArticleDto = ({ title, subtitle, content }: UpdateArticleCommand): UpdateArticleDto => ({
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 { UpdateArticleCommand } from '../+state/update-article.command';
import { UpdateArticleResponseDto } from './article-update.dto';
import { toUpdateArticleDto } from './article-update.mapper';
@Injectable()
export class ArticleCommand {
private readonly http = inject(HttpClient);
readonly updateArticle = (command: UpdateArticleCommand) => this.http.put<UpdateArticleResponseDto>(`https://lorem-api.com/api/article/${command.articleId}`, toUpdateArticleDto(command));
}

Der Name ist bewusst ArticleCommand.

Nicht ArticleResource.

Nicht ArticleStore.

Diese Klasse bündelt Write-Operationen gegen die externe API.

UpdateArticleCommand
→ fachliche Absicht
ArticleCommand
→ Infrastructure-Operationen für Write-Zugriffe

In diesem Beispiel nutze ich PUT, weil der Command die neuen Artikelwerte vollständig übergibt.

Wenn ein Feature bewusst nur einzelne Felder ändern möchte, wäre ein PATCH-Flow ein eigener Schnitt.

Der wichtige Punkt bleibt derselbe:

UpdateArticleCommand
→ toUpdateArticleDto()
→ PUT

Das DTO entsteht an der API-Grenze.


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 { signalStore, withProps } from '@ngrx/signals';
import { Events, withEventHandlers } from '@ngrx/signals/events';
import { mapResponse } from '@ngrx/operators';
import { exhaustMap, map } from 'rxjs';
import { ArticleCommand } from '../infrastructure/article.command';
import { articleReadEvents } from './article-read.events';
import { articleUpdateEvents } from './article-update.events';
export const ArticleCommandStore = signalStore(
withProps(() => ({
_articleCommand: inject(ArticleCommand),
})),
withEventHandlers(({ _articleCommand }, events = inject(Events)) => ({
updateArticle$: events.on(articleUpdateEvents.updateRequested).pipe(
exhaustMap((command) =>
_articleCommand.updateArticle(command).pipe(
mapResponse({
next: (response) =>
articleUpdateEvents.updateSucceeded({
articleId: response.id,
}),
error: (error: unknown) =>
articleUpdateEvents.updateFailed({
articleId: command.articleId,
error,
}),
}),
),
),
),
reloadOnUpdateSucceeded$: events.on(articleUpdateEvents.updateSucceeded).pipe(map(() => articleReadEvents.loadRequested())),
// notifyOnUpdateSucceeded$: events
// .on(articleUpdateEvents.updateSucceeded)
// .pipe(
// map(() =>
// notificationEvents.showSuccess({
// summary: {
// key: 'articles.notifications.update.success.summary',
// },
// detail: {
// key: 'articles.notifications.update.success.detail',
// },
// }),
// ),
// ),
// notifyOnUpdateFailed$: events
// .on(articleUpdateEvents.updateFailed)
// .pipe(
// map(() =>
// notificationEvents.showError({
// summary: {
// key: 'articles.notifications.update.error.summary',
// },
// detail: {
// key: 'articles.notifications.update.error.detail',
// },
// }),
// ),
// ),
// navigateOnUpdateSucceeded$: events
// .on(articleUpdateEvents.updateSucceeded)
// .pipe(
// map(() => articleNavigationIntentEvents.openList()),
// ),
})),
);

Der Store macht drei Dinge:

1. updateRequested entgegennehmen
2. PUT über Infrastructure ausführen
3. updateSucceeded oder updateFailed veröffentlichen

Danach übersetzt er updateSucceeded in ein Read Event:

updateSucceeded
→ 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.


Der PUT-Aufruf hat zwei mögliche Ergebnisse:

success
→ updateSucceeded
error
→ updateFailed

Genau dafür ist mapResponse gut lesbar.

Der Erfolgsfall erzeugt ein Success Event.

Der Fehlerfall erzeugt ein Error Event.

mapResponse({
next: (response) =>
articleUpdateEvents.updateSucceeded({
articleId: response.id,
}),
error: (error: unknown) =>
articleUpdateEvents.updateFailed({
articleId: command.articleId,
error,
}),
});

Das hält den Command Store deklarativ.

Kein verschachteltes subscribe.

Kein lokales Error-Handling in der Komponente.

Kein direkter Toast im Catch.

Nur:

HTTP-Ergebnis
→ Event

Auch Update kann doppelt ausgelöst werden.

Der Benutzer klickt zweimal.

Das Formular submitet zweimal.

Das Netzwerk hängt.

Für diesen Artikel nutze ich deshalb exhaustMap.

exhaustMap
→ ignoriert weitere Updates, solange eins läuft
concatMap
→ queued Updates nacheinander
mergeMap
→ erlaubt parallele Updates
switchMap
→ bricht alte Updates ab

Bei Commands ist switchMap oft gefährlich.

Ein Update ist keine Suchanfrage.

Wenn ein Update 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.


Nach einem erfolgreichen Update wollen wir in diesem Artikel neu laden.

Später vielleicht auch eine Notification zeigen oder zurück zur Liste navigieren.

Wichtig ist die Richtung:

updateSucceeded
→ articleReadEvents.loadRequested()
updateSucceeded
→ notificationEvents.showSuccess(...)
updateSucceeded
→ articleNavigationIntentEvents.openList()

Das sind Event-Reaktionen.

Keine direkten imperativen Side Effects im Command Store.

Deshalb nutze ich hier map.

Aus einem Event wird ein neues Event.

reloadOnUpdateSucceeded$: events
.on(articleUpdateEvents.updateSucceeded)
.pipe(
map(() => articleReadEvents.loadRequested()),
),

Das ist testbarer als:

tap(() => articleStore.reload());

Und es hält die Kopplung klein.

Der Command Store sagt nicht:

Read Store, lade dich neu.

Er sagt nur:

Ein Load wurde angefordert.

Die Read-Seite entscheidet selbst, was das bedeutet.

Auch Notifications können so modelliert werden:

notifyOnUpdateSucceeded$: events
.on(articleUpdateEvents.updateSucceeded)
.pipe(
map(() =>
notificationEvents.showSuccess({
summary: {
key: 'articles.notifications.update.success.summary',
},
detail: {
key: 'articles.notifications.update.success.detail',
},
}),
),
),

Dieser Artikel implementiert Notification und Navigation nicht vollständig.

Er deutet sie nur an.

Der Punkt ist:

updateSucceeded
→ loadRequested
→ showSuccess
→ openList

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

tap gehört an echte imperative Grenzen: Router, Toast-Service, Logging oder externe APIs.


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 Read Stores.

Es ist Lifecycle-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.

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:

loadRequested
→ ArticleResource.reload()

Davor werden Events auf Events abgebildet.

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

Der Store interpretiert dabei keinen technischen Resource-Lifecycle.

Er orchestriert lediglich:

article
→ ArticleVm
loadRequested
→ ArticleResource.reload()

Das hält den Lesefluss ruhig.

Beim Lesen des Stores interessiert:

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

Nicht die Gebrauchsanweisung von httpResource.


Die Update-Facade führt keinen HTTP-Aufruf aus.

Sie kennt keinen HttpClient.

Sie kennt auch die Resource nicht.

Sie veröffentlicht nur die Absicht.

import { Injectable } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import { UpdateArticleCommand } from '../+state/update-article.command';
import { articleUpdateEvents } from '../+state/article-update.events';
@Injectable()
export class ArticleUpdateFacade {
private readonly dispatchUpdate = injectDispatch(articleUpdateEvents);
readonly updateArticle = (command: UpdateArticleCommand): void => {
this.dispatchUpdate.updateRequested(command);
};
}

Das ist die öffentliche API der Edit-UI:

updateArticle(command)

Die Facade aktualisiert keinen Artikel.

Sie veröffentlicht die Absicht, einen Artikel zu ändern.

Presentation
→ facade.updateArticle(command)
→ articleUpdateEvents.updateRequested(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.

Validierung, Dirty State, Konflikterkennung und Submit-Status 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 { ArticleUpdateFacade } from '../application/article-update.facade';
import { ArticleCommand } from '../infrastructure/article.command';
import { ArticleResource } from '../infrastructure/article.resource';
export interface ArticleEditModel {
readonly articleId: string;
readonly title: string;
readonly subtitle: string;
readonly content: string;
}
const existingArticle: ArticleEditModel = {
articleId: 'foo',
title: 'Existing title',
subtitle: 'Existing subtitle',
content: 'Existing content',
};
@Component({
selector: 'app-article-edit-page',
templateUrl: './article-edit-page.component.html',
providers: [ArticleResource, ArticleCommand, ArticleStore, ArticleCommandStore, ArticleUpdateFacade],
})
export class ArticleEditPageComponent {
protected readonly facade = inject(ArticleUpdateFacade);
protected readonly model = signal<ArticleEditModel>(existingArticle);
protected readonly articleForm = form(this.model);
}

existingArticle steht hier bewusst stellvertretend für den bereits geladenen Artikel.

In einer echten Anwendung käme dieser Wert aus einem Detail-Retrieve-Slice, einem Resolver, einem Route-Input oder einem Store-Signal.

Ich zeige das hier nicht aus, weil der Artikel sonst sofort zu einem Selected-Id- oder byId-Artikel würde.

Hier geht es um den Update-Submit:

Form model
→ UpdateArticleCommand
→ updateRequested
<form
class="article-edit"
(ngSubmit)="
facade.updateArticle({
articleId: articleId(),
title: model().title,
subtitle: model().subtitle,
content: model().content,
})
"
>
<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">Änderungen speichern</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 auf.

Mehr nicht.

Form ändern
→ ngSubmit
→ facade.updateArticle(command)

Der Submit ist kein Orchestrator.

Er sendet nur die Änderungsabsicht.


Für den Artikel kann der Slice lokal an der Page oder Route bereitgestellt werden.

providers: [ArticleResource, ArticleCommand, ArticleStore, ArticleCommandStore, ArticleUpdateFacade];

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
ArticleUpdateFacade

bilden gemeinsam den Scope dieses Update-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 updateRequested
→ feuert updateSucceeded / updateFailed

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

Der fachliche Schnitt bleibt davon unberührt.


In einem echten Feature steht Update selten allein.

Create, Update und Delete teilen häufig dieselben Reaktionen.

Zum Beispiel:

createSucceeded
updateSucceeded
deleteSucceeded
→ loadRequested

Dann kann der Command Store diese Success Events gemeinsam behandeln:

reloadOnWriteSucceeded$: events
.on(
articleCreateEvents.createSucceeded,
articleUpdateEvents.updateSucceeded,
articleDeleteEvents.deleteSucceeded,
)
.pipe(
map(() => articleReadEvents.loadRequested()),
),

Das ist stärker als drei einzelne Reload-Aufrufe in drei Komponenten.

Der Reload hängt nicht am Button.

Er hängt auch nicht am konkreten Command.

Er hängt am erfolgreichen Write.

Write succeeded
→ Read neu anfordern

Das ist die eigentliche Abstraktion.

Auch Navigation kann so gemeinsam modelliert werden:

navigateOnWriteSucceeded$: events
.on(
articleCreateEvents.createSucceeded,
articleUpdateEvents.updateSucceeded,
)
.pipe(
map(() => articleNavigationIntentEvents.openList()),
),

Delete muss nicht automatisch in dieselbe Navigation fallen.

Das ist eine fachliche Entscheidung.

Der Punkt ist:

Reaktionen hängen am Ergebnis.

Nicht am Button.


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

Ein Fehler ist ebenfalls ein Ergebnis des Commands.

updateRequested
→ PUT
→ updateFailed({ articleId, error })

Was danach passiert, ist wieder eine Reaktion:

updateFailed
→ notificationEvents.showError(...)
updateFailed
→ // keep form open
updateFailed
→ // mark form as failed

Für diesen Artikel reicht das Event.

Eine echte UI könnte später auf updateFailed reagieren und einen Toast anzeigen, das Formular offen lassen oder Felder markieren.

Wichtig ist:

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

Er veröffentlicht nur:

Das Update ist fehlgeschlagen.


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

Das stimmt.

Ein direkter http.put()-Aufruf ist kürzer.

Aber kürzer ist nicht automatisch einfacher.

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

Form kennt Update
Form kennt HTTP
Form kennt Reload
Form kennt Toast
Form kennt Navigation
Form kennt Fehlerbehandlung

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

Intent
→ Command
→ Result
→ Reaction

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

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

Also zum Beispiel:

  • Liste neu laden
  • Detailseite aktualisieren
  • zurück zur Liste navigieren
  • Toast anzeigen
  • Dialog schließen
  • Cache invalidieren
  • mehrere Read Models aktualisieren
  • optimistischen Zustand zurückrollen
  • Konfliktstatus anzeigen

Dann ist ein Event kein akademischer Umweg.

Es ist eine Entkopplung.


Ein Update ist kein schneller HTTP-Aufruf aus der Komponente.

Ein Update ist ein Command.

Die UI sollte diese Absicht nicht selbst orchestrieren.

Sie sendet nur:

updateArticle(command)
→ updateRequested(command)

Der Command Store führt den Write aus.

Die Infrastructure spricht mit der API.

Success und Error werden als Events sichtbar.

Reload, Notification und Navigation hängen nicht am Submit.

Sie hängen am Ergebnis.

updateRequested
→ ArticleCommand.updateArticle(command)
→ updateSucceeded | updateFailed
updateSucceeded
→ articleReadEvents.loadRequested()
→ notificationEvents.showSuccess(...)
→ articleNavigationIntentEvents.openList()
updateFailed
→ notificationEvents.showError(...)

So bleibt die Änderungsabsicht explizit.

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 die Komponente wird nicht zum Ort, an dem Formular, HTTP, Nachladen, Navigation und Toasts zusammenkleben.