Delete Slice: Löschen ist eine fachliche Entscheidung
Daten zu löschen ist ein Command.
Aber nicht irgendein Command.
Ein Delete ist destruktiv.
Das klingt banal.
Ist es aber nicht.
Denn genau deshalb sollte ein Delete-Flow nicht irgendwo als schneller Button-Handler enden:
click → http.delete() → reload() → toast()Das ist technisch kurz.
Aber architektonisch schwach.
Die Komponente kennt dann zu viel:
- sie kennt die destruktive Aktion
- sie kennt HTTP
- sie kennt Reload
- sie kennt Notification
- sie entscheidet vielleicht noch über Fehlerbehandlung
In diesem Artikel schneide ich Delete deshalb genauso bewusst wie Create:
delete intent → facade.deleteArticle(articleId) → articleDeleteEvents.deleteRequested({ articleId }) → ArticleCommandStore → ArticleCommand.deleteArticle(command) → deleteSucceeded | deleteFailed → reload / notificationDer Button löscht nicht.
Der Button sendet eine destruktive Absicht.
Der Schnitt
Abschnitt betitelt „Der Schnitt“
Der wichtigste Punkt:
Delete ist keine lokale UI-Aktion.
Delete ist eine Absicht, die das System verändern soll.
Diese Absicht kann erfolgreich sein.
Sie kann scheitern.
Und auf beide Ergebnisse können mehrere unabhängige Reaktionen folgen.
deleteRequested → DELETE → deleteSucceeded | deleteFailed → unabhängige ReaktionenDas ist ein anderer Schnitt als:
click → http.delete() → reloadList() → toast()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: Confirm
Abschnitt betitelt „Was dieser Artikel bewusst nicht zeigt: Confirm“Destruktive Aktionen sollten in echten Anwendungen abgefangen werden.
Ein Delete braucht häufig eine Sicherheitsfrage:
delete clicked → confirm → deleteRequestedOder eine Undo-Mechanik:
delete clicked → soft delete → undo möglich → endgültig löschenDas ist wichtig.
Aber es ist nicht der Fokus dieses Artikels.
Hier geht es nicht um Dialog-Design, Undo-Pattern, Soft Delete oder rechtliche Aufbewahrungspflichten.
Hier geht es um den technischen Slice nach der Entscheidung:
Der Benutzer hat bestätigt: Dieser Artikel soll gelöscht werden.
Ab diesem Moment wird aus UI-Interaktion ein Command.
Das Confirm gehört vor den Intent.
Nicht mitten in den Command Store.
Warum nicht im Read Store löschen?
Abschnitt betitelt „Warum nicht im Read Store löschen?“Die Versuchung ist groß.
Der Read Store lädt sowieso die Artikel.
Also bekommt er schnell noch eine Methode:
deleteArticle(articleId: string): void { this.http.delete(`/api/article/${articleId}`).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 löscht Daten.
Er behandelt Fehler.
Er triggert Reload.
Vielleicht zeigt er später noch Toasts.
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 deleteRequested → führt DELETE aus → feuert deleteSucceeded / deleteFailed → leitet Success-Reaktionen als Events weiterDer Read Store bleibt ein Read Store.
Der Command Store bleibt ein Command Store.
Zielstruktur
Abschnitt betitelt „Zielstruktur“Eine mögliche Struktur für diesen Slice:
article/├── infrastructure/│ ├── article.command.ts│ ├── article.dto.ts│ ├── article.mapper.ts│ └── article.resource.ts├── +state/│ ├── article.store.ts│ ├── article.vm.ts│ ├── article-view-model.mapper.ts│ ├── delete-article.command.ts│ ├── article-delete.events.ts│ ├── article-read.events.ts│ └── article-command.store.ts├── application/│ └── article-delete.facade.ts└── presentation/ └── article-card.component.htmlWenn Create und Delete im selben Feature leben, kann der ArticleCommandStore natürlich beide Write-Flows behandeln.
Dann sieht die Command-Seite eher so aus:
+state/├── create-article.command.ts├── delete-article.command.ts├── article-create.events.ts├── article-delete.events.ts└── article-command.store.ts
infrastructure/└── article.command.tsDas Pattern bleibt gleich:
ArticleCommandStore → nutzt ArticleCommandDer Store entscheidet, wann eine Write-Operation ausgeführt wird.
Die Infrastructure entscheidet, wie dieser Write gegen die externe API übersetzt wird.
1. Command: die destruktive Absicht beschreiben
Abschnitt betitelt „1. Command: die destruktive Absicht beschreiben“Ein Delete braucht nicht viel Payload.
Aber die Absicht sollte trotzdem explizit sein.
export interface DeleteArticleCommand { readonly articleId: string;}Das ist kein DTO.
Das ist kein ViewModel.
Das ist kein Button-Event.
Das ist die fachliche Absicht:
DeleteArticleCommand = Benutzer möchte diesen Artikel löschenBei Delete ist diese Explizitheit wichtig.
Denn wir modellieren keine technische HTTP-Methode.
Wir modellieren eine destruktive Handlung.
2. Events: requested, succeeded, failed
Abschnitt betitelt „2. Events: requested, succeeded, failed“Jetzt definieren wir die Events des Delete-Slice.
import { eventGroup, type } from '@ngrx/signals/events';
import { DeleteArticleCommand } from './delete-article.command';
export const articleDeleteEvents = eventGroup({ source: 'Article Delete', events: { deleteRequested: type<DeleteArticleCommand>(), deleteSucceeded: type<{ readonly articleId: string }>(), deleteFailed: type<{ readonly articleId: string; readonly error: unknown; }>(), },});Die Sprache ist wieder wichtiger als die Syntax:
deleteRequesteddeleteSucceededdeleteFaileddeleteRequested ist noch kein Ergebnis.
Es ist eine Absicht.
deleteSucceeded und deleteFailed sind Ergebnisse der Ausführung.
Ich nehme die articleId auch in deleteFailed mit auf.
Warum?
Weil ein Fehler ohne Kontext oft wenig wert ist.
Gerade in Listen möchte man wissen:
Welcher Delete ist fehlgeschlagen?Das macht Fehlerreaktionen testbarer und nachvollziehbarer.
3. Read Event: Reload als eigener Intent
Abschnitt betitelt „3. Read Event: Reload als eigener Intent“Nach einem erfolgreichen Delete sollen die gelesenen Daten aktualisiert werden.
Aber der Command Store soll den Read Store nicht direkt kennen.
Nicht so:
deleteSucceeded → articleStore.reload()Sondern so:
deleteSucceeded → articleReadEvents.loadRequested() → Read Store stößt ArticleResource.reload() anDafü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.
4. Infrastructure: DELETE an der API-Grenze
Abschnitt betitelt „4. Infrastructure: DELETE an der API-Grenze“Die Infrastructure bündelt die Write-Operationen.
In diesem Slice liegt dafür eine Datei article.command.ts.
import { HttpClient } from '@angular/common/http';import { Injectable, inject } from '@angular/core';
import { DeleteArticleCommand } from '../+state/delete-article.command';
@Injectable()export class ArticleCommand { private readonly http = inject(HttpClient);
readonly deleteArticle = ({ articleId }: DeleteArticleCommand) => this.http.delete<void>(`https://lorem-api.com/api/article/${articleId}`);}Der Name ist bewusst ArticleCommand.
Nicht ArticleResource.
Nicht ArticleStore.
Diese Klasse bündelt Write-Operationen gegen die externe API.
DeleteArticleCommand → fachliche Absicht
ArticleCommand → Infrastructure-Operationen für Write-ZugriffeBei Create entsteht hier häufig ein DTO.
Bei Delete reicht oft die ID in der URL.
Wenn die externe API später ein anderes Format braucht, bleibt diese Entscheidung an der API-Grenze.
Nicht in der Komponente.
Nicht in der Facade.
Nicht im Read Store.
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 { articleDeleteEvents } from './article-delete.events';import { articleReadEvents } from './article-read.events';
export const ArticleCommandStore = signalStore( withProps(() => ({ _articleCommand: inject(ArticleCommand), })),
withEventHandlers(({ _articleCommand }, events = inject(Events)) => ({ deleteArticle$: events.on(articleDeleteEvents.deleteRequested).pipe( exhaustMap((command) => _articleCommand.deleteArticle(command).pipe( mapResponse({ next: () => articleDeleteEvents.deleteSucceeded({ articleId: command.articleId, }), error: (error: unknown) => articleDeleteEvents.deleteFailed({ articleId: command.articleId, error, }), }), ), ), ),
reloadOnDeleteSucceeded$: events.on(articleDeleteEvents.deleteSucceeded).pipe(map(() => articleReadEvents.loadRequested())),
// notifyOnDeleteSucceeded$: events // .on(articleDeleteEvents.deleteSucceeded) // .pipe( // map(() => // notificationEvents.showSuccess({ // summary: { // key: 'articles.notifications.delete.success.summary', // }, // detail: { // key: 'articles.notifications.delete.success.detail', // }, // }), // ), // ),
// notifyOnDeleteFailed$: events // .on(articleDeleteEvents.deleteFailed) // .pipe( // map(() => // notificationEvents.showError({ // summary: { // key: 'articles.notifications.delete.error.summary', // }, // detail: { // key: 'articles.notifications.delete.error.detail', // }, // }), // ), // ), })),);Der Store macht drei Dinge:
1. deleteRequested entgegennehmen2. DELETE über Infrastructure ausführen3. deleteSucceeded oder deleteFailed veröffentlichenDanach übersetzt er deleteSucceeded in ein Read Event:
deleteSucceeded → loadRequestedDas 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.
Warum mapResponse?
Abschnitt betitelt „Warum mapResponse?“Der DELETE-Aufruf hat zwei mögliche Ergebnisse:
success → deleteSucceeded
error → deleteFailedGenau dafür ist mapResponse gut lesbar.
Der Erfolgsfall erzeugt ein Success Event.
Der Fehlerfall erzeugt ein Error Event.
mapResponse({ next: () => articleDeleteEvents.deleteSucceeded({ articleId: command.articleId, }), error: (error: unknown) => articleDeleteEvents.deleteFailed({ 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 → EventWarum exhaustMap?
Abschnitt betitelt „Warum exhaustMap?“Auch Delete kann doppelt ausgelöst werden.
Der Benutzer klickt zweimal.
Die Liste rendert langsam.
Ein Button bleibt zu lange aktiv.
Für diesen Artikel nutze ich deshalb exhaustMap.
exhaustMap → ignoriert weitere Deletes, solange einer läuft
concatMap → queued Deletes nacheinander
mergeMap → erlaubt parallele Deletes
switchMap → bricht alte Deletes abBei destruktiven Commands ist switchMap meist keine gute Grundeinstellung.
Ein Delete ist keine Suchanfrage.
Wenn ein Delete einmal unterwegs ist, will ich ihn normalerweise nicht still abbrechen, nur weil ein zweites Event kommt.
exhaustMap ist für diesen einfachen Slice die konservative Wahl.
6. Success-Reaktionen bleiben Events
Abschnitt betitelt „6. Success-Reaktionen bleiben Events“Nach einem erfolgreichen Delete wollen wir in diesem Artikel neu laden.
Später vielleicht auch eine Notification zeigen.
Wichtig ist die Richtung:
deleteSucceeded → articleReadEvents.loadRequested()
deleteSucceeded → notificationEvents.showSuccess(...)Das sind Event-Reaktionen.
Keine direkten imperativen Side Effects im Command Store.
Deshalb nutze ich hier map.
Aus einem Event wird ein neues Event.
reloadOnDeleteSucceeded$: events .on(articleDeleteEvents.deleteSucceeded) .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:
notifyOnDeleteSucceeded$: events .on(articleDeleteEvents.deleteSucceeded) .pipe( map(() => notificationEvents.showSuccess({ summary: { key: 'articles.notifications.delete.success.summary', }, detail: { key: 'articles.notifications.delete.success.detail', }, }), ), ),Dieser Artikel implementiert die Notification nicht vollständig.
Er deutet sie nur an.
Der Punkt ist:
deleteSucceeded → loadRequested → showSuccessSolange 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.
7. Read Store: auf loadRequested reagieren
Abschnitt betitelt „7. Read Store: auf loadRequested reagieren“Die konkrete Angular Resource bleibt 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(): voidDer Read Store kennt deshalb keine HttpResourceRef.
Er kennt weder hasValue() noch das Wurfverhalten von value().
Er orchestriert nur die von ArticleResource bereitgestellten Source Signals, projiziert daraus das ViewModel und reagiert auf articleReadEvents.loadRequested.
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 erhält dabei:
Article | null → ArticleVm | nullDie Domain Entity selbst bleibt strikt.
Es gibt keinen künstlichen leeren Artikel, nur um den technischen Lifecycle zu verstecken.
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 Infrastructure-Operation 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 einen Read-Intent zu einer klar benannten Operation seiner Infrastructure-Abhängigkeit.
Das ist die gewünschte Rollenverteilung:
private httpResource → ArticleResource → Source Signals und reload() → Read Store → ViewModelDer Store entscheidet weiterhin, welche Signale der Slice nach außen anbietet und wie daraus das ViewModel entsteht.
Er entscheidet aber nicht mehr fallweise, ob eine Angular Resource gerade sicher gelesen werden darf.
Das hält den Lesefluss ruhig:
article → ViewModelloadRequested → reload()Beim Lesen des Stores interessiert die Orchestrierung.
Nicht die Gebrauchsanweisung der darunterliegenden Framework-API.
8. Application: Facade sendet Intent
Abschnitt betitelt „8. Application: Facade sendet Intent“Die Delete-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 { articleDeleteEvents } from '../+state/article-delete.events';
@Injectable()export class ArticleDeleteFacade { private readonly dispatchDelete = injectDispatch(articleDeleteEvents);
readonly deleteArticle = (articleId: string): void => { this.dispatchDelete.deleteRequested({ articleId }); };}Das ist die öffentliche API der Delete-UI:
deleteArticle(articleId)Die Facade löscht keinen Artikel.
Sie veröffentlicht die Absicht, einen Artikel zu löschen.
Presentation → facade.deleteArticle(articleId) → articleDeleteEvents.deleteRequested({ articleId })Was danach passiert, gehört nicht mehr der Presentation.
9. Presentation: Button sendet destruktive Absicht
Abschnitt betitelt „9. Presentation: Button sendet destruktive Absicht“Das Template bleibt klein.
<button type="button" class="article-card__delete" (click)="facade.deleteArticle(article.id)">Artikel löschen</button>Das Beispiel zeigt bewusst keinen Confirm-Dialog.
Nicht, weil Confirm unwichtig wäre.
Sondern weil Confirm eine UI-Sicherheitsentscheidung vor dem Command ist.
Der hier gezeigte Slice beginnt erst nach dieser Entscheidung:
Benutzer will löschen → deleteRequested({ articleId })In einer echten Anwendung würde ich destruktive Aktionen fast immer absichern: Confirm, Undo, Soft Delete oder rollenabhängige Berechtigungen.
Aber diese Sicherheitsmechanik sollte den Command-Flow nicht verdecken.
Der Button ruft hier nur die Facade auf.
Die Facade sendet den Intent.
Alles Weitere liegt im Event-Flow.
10. Provider und Lifecycle
Abschnitt betitelt „10. Provider und Lifecycle“Für den Artikel kann der Slice lokal an der Page oder Route bereitgestellt werden.
providers: [ArticleResource, ArticleCommand, ArticleStore, ArticleCommandStore, ArticleDeleteFacade];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:
ArticleResourceArticleCommandArticleStoreArticleCommandStoreArticleDeleteFacadebilden gemeinsam den Scope dieses Delete-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 deleteRequested → feuert deleteSucceeded / deleteFailedWie genau dieser Lifecycle im Projekt verdrahtet wird, ist eine Framework-Mechanik.
Der fachliche Schnitt bleibt davon unberührt.
11. Delete, Create und gemeinsame Write-Reaktionen
Abschnitt betitelt „11. Delete, Create und gemeinsame Write-Reaktionen“In einem echten Feature steht Delete selten allein.
Create, Update und Delete teilen häufig dieselben Reaktionen.
Zum Beispiel:
createSucceededupdateSucceededdeleteSucceeded → loadRequestedDann 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 anfordernDas ist die eigentliche Abstraktion.
12. Error ist ebenfalls ein Ergebnis
Abschnitt betitelt „12. Error ist ebenfalls ein Ergebnis“Auch Fehler sollten nicht einfach als lokales catchError in der Komponente enden.
Ein Fehler ist ebenfalls ein Ergebnis des Commands.
deleteRequested → DELETE → deleteFailed({ articleId, error })Was danach passiert, ist wieder eine Reaktion:
deleteFailed → notificationEvents.showError(...)
deleteFailed → // keep row visible
deleteFailed → // rollback optimistic stateFür diesen Artikel reicht das Event.
Eine echte UI könnte später auf deleteFailed reagieren und einen Toast anzeigen, einen Dialog offen lassen oder einen optimistischen UI-Zustand zurücknehmen.
Wichtig ist:
Der Command Store entscheidet nicht, wie der Fehler angezeigt wird.
Er veröffentlicht nur:
Der Delete ist fehlgeschlagen.
13. Warum das kein Overengineering ist
Abschnitt betitelt „13. Warum das kein Overengineering ist“Für einen einzelnen Button kann dieser Schnitt nach mehr Code aussehen.
Das stimmt.
Ein direkter http.delete()-Aufruf ist kürzer.
Aber kürzer ist nicht automatisch einfacher.
Ein direkter Aufruf ist oft nur deshalb kurz, weil er Kopplung versteckt.
Button kennt DeleteButton kennt HTTPButton kennt ReloadButton kennt ToastButton kennt FehlerbehandlungDer event-driven Slice macht diese Übergänge sichtbar.
Intent → Command → Result → ReactionDas ist nicht für jeden kleinen Button nötig.
Aber es ist wertvoll, sobald ein Delete mehr als eine lokale Folge hat.
Also zum Beispiel:
- Liste neu laden
- Detailseite verlassen
- Toast anzeigen
- Dialog schließen
- Cache invalidieren
- mehrere Read Models aktualisieren
- optimistischen Zustand zurückrollen
Dann ist ein Event kein akademischer Umweg.
Es ist eine Entkopplung.
Ein Delete ist kein schneller HTTP-Aufruf aus der Komponente.
Ein Delete ist ein destruktiver Command.
Die UI sollte diese Absicht nicht selbst orchestrieren.
Sie sendet nur:
deleteArticle(articleId) → deleteRequested({ articleId })Der Command Store führt den Write aus.
Die Infrastructure spricht mit der API.
Success und Error werden als Events sichtbar.
Reload und Notification hängen nicht am Button.
Sie hängen am Ergebnis.
deleteRequested → ArticleCommand.deleteArticle(command) → deleteSucceeded | deleteFailed
deleteSucceeded → articleReadEvents.loadRequested() → notificationEvents.showSuccess(...)
deleteFailed → notificationEvents.showError(...)So bleibt die destruktive Aktion explizit.
Der Read Store bleibt 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 die Komponente wird nicht zum Ort, an dem Löschen, Nachladen, Navigation und Toasts zusammenkleben.