Delete Slice:删除是一项业务决策
删除数据是一种 Command。
但不是普通的 Command。
Delete 是破坏性操作。
这听起来很显然。
但正因为如此,Delete Flow 不应该最后只是某个 Button Handler 里的快捷调用:
click → http.delete() → reload() → toast()技术上很短。
但架构上很弱。
Component 会因此知道得太多:
- 它知道破坏性操作;
- 它知道 HTTP;
- 它知道 Reload;
- 它知道 Notification;
- 它甚至可能顺手决定错误怎么处理。
因此,这篇文章会像 Create 一样,有意识地切分 Delete:
delete intent → facade.deleteArticle(articleId) → articleDeleteEvents.deleteRequested({ articleId }) → ArticleCommandStore → ArticleCommand.deleteArticle(command) → deleteSucceeded | deleteFailed → reload / notificationButton 不负责删除。
Button 只发送一个破坏性的 Intent。

最重要的一点:
Delete 不是一个局部 UI Action。
Delete 是一个希望改变系统的 Intent。
它可能成功。
也可能失败。
而成功和失败都可以触发多个彼此独立的后续反应。
deleteRequested → DELETE → deleteSucceeded | deleteFailed → unabhängige Reaktionen这与下面这种切法不同:
click → http.delete() → reloadList() → toast()在直接流程里,每一步都过早知道下一步要做什么。
在 Event-driven Slice 中,Intent、执行与后续反应彼此分离。
这篇文章刻意不讨论什么:Confirm
Section titled “这篇文章刻意不讨论什么:Confirm”真实应用中的破坏性操作通常都应该有保护。
Delete 经常需要一次安全确认:
delete clicked → confirm → deleteRequested或者提供 Undo:
delete clicked → soft delete → undo möglich → endgültig löschen这些都很重要。
但不是本文的重点。
这里不讨论 Dialog Design、Undo Pattern、Soft Delete 或法律层面的数据保留义务。
本文关注的是做出下面这个决定之后的技术 Slice:
用户已经确认:这个 Article 应该被删除。
从这一刻开始,UI Interaction 转换成一个 Command。
Confirm 应该发生在 Intent 之前。
而不是塞进 Command Store 中间。
为什么不直接在 Read Store 里删除?
Section titled “为什么不直接在 Read Store 里删除?”这种诱惑很大。
Read Store 反正已经在加载 Article。
于是很容易顺手再加一个方法:
deleteArticle(articleId: string): void { this.http.delete(`/api/article/${articleId}`).subscribe(() => { this.reload(); });}这样当然能工作。
但 Store 的角色已经改变了。
它不再只是 Read Model。
它突然也成了 Command Handler。
它加载数据。
它持有 ViewModel。
它删除数据。
它处理错误。
它触发 Reload。
以后也许还会顺手显示 Toast。
最后它虽然仍叫 ArticleStore,实际上已经成了一个小型上帝对象。
这个 Slice 正是要避免这一点。
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 weiterRead Store 继续只做 Read Store。
Command Store 继续只做 Command Store。
这个 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.html如果 Create 与 Delete 位于同一个 Feature 中,ArticleCommandStore 当然可以同时处理两条 Write Flow。
Command 侧就会更像这样:
+state/├── create-article.command.ts├── delete-article.command.ts├── article-create.events.ts├── article-delete.events.ts└── article-command.store.ts
infrastructure/└── article.command.tsPattern 保持不变:
ArticleCommandStore → nutzt ArticleCommandStore 决定什么时候执行 Write Operation。
Infrastructure 决定如何把这个 Write 翻译成外部 API 所需的形式。
1. Command:描述破坏性 Intent
Section titled “1. Command:描述破坏性 Intent”Delete 不需要很多 Payload。
但它的 Intent 仍然应该被显式描述。
export interface DeleteArticleCommand { readonly articleId: string;}这不是 DTO。
不是 ViewModel。
也不是 Button Event。
它是业务 Intent:
DeleteArticleCommand = Benutzer möchte diesen Artikel löschen对 Delete 来说,这种显式性尤其重要。
因为我们建模的不是某个技术性的 HTTP Method。
而是一项破坏性业务操作。
2. Events:requested、succeeded、failed
Section titled “2. Events:requested、succeeded、failed”接下来定义 Delete Slice 的 Events。
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; }>(), },});同样,语言比语法更重要:
deleteRequesteddeleteSucceededdeleteFaileddeleteRequested 还不是结果。
它只是一个 Intent。
deleteSucceeded 和 deleteFailed 才是执行后的结果。
我也会把 articleId 放进 deleteFailed。
为什么?
因为没有上下文的错误通常没有太大价值。
尤其在列表里,我们需要知道:
Welcher Delete ist fehlgeschlagen?这样,错误反应更容易测试,也更容易追踪。
3. Read Event:把 Reload 建模为独立 Intent
Section titled “3. Read Event:把 Reload 建模为独立 Intent”Delete 成功之后,需要更新已经读取的数据。
但 Command Store 不应该直接认识 Read Store。
不要这样:
deleteSucceeded → articleStore.reload()而是这样:
deleteSucceeded → articleReadEvents.loadRequested() → Read Store stößt ArticleResource.reload() an为此我们定义一个 Read Event:
import { eventGroup, type } from '@ngrx/signals/events';
export const articleReadEvents = eventGroup({ source: 'Article Read', events: { loadRequested: type<void>(), },});这看起来多了一层很小的间接关系。
但这一层恰好就是边界。
Write 侧不规定 Read 侧如何加载。
它只表达:
这次 Write 成功之后,应该重新请求 Article 的 Read Data。
4. Infrastructure:在 API Boundary 上执行 DELETE
Section titled “4. Infrastructure:在 API Boundary 上执行 DELETE”Infrastructure 集中封装 Write Operation。
这个 Slice 使用 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}`);}名字刻意叫 ArticleCommand。
不是 ArticleResource。
也不是 ArticleStore。
这个类集中封装面向外部 API 的 Write Operation。
DeleteArticleCommand → fachliche Absicht
ArticleCommand → Infrastructure-Operationen für Write-ZugriffeCreate 往往会在这里生成 DTO。
Delete 通常只需要 URL 中的 ID。
如果外部 API 将来需要其他格式,这个决定仍然停留在 API Boundary。
不在 Component。
不在 Facade。
也不在 Read Store。
5. Command Store:执行 requested,发布结果
Section titled “5. Command Store:执行 requested,发布结果”现在来到 Command Store。
它不持有 ViewModel。
不负责 Rendering。
它监听 Events,调用 Infrastructure,并发布结果 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', // }, // }), // ), // ), })),);Store 做三件事:
1. deleteRequested entgegennehmen2. DELETE über Infrastructure ausführen3. deleteSucceeded oder deleteFailed veröffentlichen然后把 deleteSucceeded 转换成一个 Read Event:
deleteSucceeded → loadRequested这里刻意不直接调用 Read Store。
Command Store 不认识 Resource。
不认识 ViewModel。
也不认识 Presentation。
它只认识 Events。
为什么使用 mapResponse?
Section titled “为什么使用 mapResponse?”DELETE 调用只有两个可能结果:
success → deleteSucceeded
error → deleteFailedmapResponse 很适合把这两个结果清楚表达出来。
成功分支生成 Success Event。
失败分支生成 Error Event。
mapResponse({ next: () => articleDeleteEvents.deleteSucceeded({ articleId: command.articleId, }), error: (error: unknown) => articleDeleteEvents.deleteFailed({ articleId: command.articleId, error, }),});这样 Command Store 可以保持声明式。
没有嵌套 subscribe。
没有 Component 内的局部 Error Handling。
也没有 Catch 里直接显示 Toast。
只有:
HTTP-Ergebnis → Event为什么使用 exhaustMap?
Section titled “为什么使用 exhaustMap?”Delete 也可能被重复触发。
用户点了两次。
列表 Rendering 很慢。
Button 保持可点击的时间过长。
因此本文使用 exhaustMap。
exhaustMap → ignoriert weitere Deletes, solange einer läuft
concatMap → queued Deletes nacheinander
mergeMap → erlaubt parallele Deletes
switchMap → bricht alte Deletes ab对于破坏性 Command,switchMap 通常不是一个好的默认选择。
Delete 不是搜索请求。
一旦一个 Delete 已经发出,通常不希望仅仅因为第二个 Event 到来,就悄悄取消第一次操作。
对于这个简单 Slice,exhaustMap 是更保守的选择。
6. Success 后的反应继续使用 Events
Section titled “6. Success 后的反应继续使用 Events”本文中,Delete 成功之后需要重新加载数据。
以后也许还要显示 Notification。
重要的是方向:
deleteSucceeded → articleReadEvents.loadRequested()
deleteSucceeded → notificationEvents.showSuccess(...)这些都是对 Event 的反应。
而不是 Command Store 里直接执行的命令式 Side Effect。
因此这里使用 map。
从一个 Event 生成另一个 Event。
reloadOnDeleteSucceeded$: events .on(articleDeleteEvents.deleteSucceeded) .pipe( map(() => articleReadEvents.loadRequested()), ),这比下面的方式更容易测试:
tap(() => articleStore.reload());而且耦合更小。
Command Store 不会说:
Read Store,你现在重新加载。
它只会说:
有一个 Load 被请求了。
Read 侧自己决定这意味着什么。
Notification 也可以用同样方式建模:
notifyOnDeleteSucceeded$: events .on(articleDeleteEvents.deleteSucceeded) .pipe( map(() => notificationEvents.showSuccess({ summary: { key: 'articles.notifications.delete.success.summary', }, detail: { key: 'articles.notifications.delete.success.detail', }, }), ), ),本文不会完整实现 Notification。
这里只指出这个方向。
关键在于:
deleteSucceeded → loadRequested → showSuccess只要一个反应仍然可以描述成 Event,map 通常就是更清晰的选择。
tap 应该留给真正的命令式 Boundary:Router、Toast Service、Logging 或外部 API。
7. Read Store:响应 loadRequested
Section titled “7. Read Store:响应 loadRequested”具体的 Angular Resource 继续留在 Infrastructure。
ArticleResource 私有持有 httpResource,对外只提供很小、命名明确的 API:
article: Signal<Article | null>isLoading: Signal<boolean>error: Signal<Error | undefined>reload(): void因此 Read Store 不认识 HttpResourceRef。
它既不知道 hasValue(),也不知道 value() 在什么情况下会抛错。
它只编排 ArticleResource 提供的 Source Signal,从中投影 ViewModel,并响应 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())), })),);ViewModel Mapper 接收的是:
Article | null → ArticleVm | nullDomain Entity 本身继续保持严格。
不会为了隐藏技术 Lifecycle,就人为制造一个“空 Article”。
这里使用 tap 是合理的。
不是因为 Store 自己执行 HTTP 或 Resource Logic。
而是因为 Event Flow 在这里确实要触发一个显式 Infrastructure Operation:
loadRequested → ArticleResource.reload()在此之前,我们都只是把 Event 映射成另一个 Event。
到了这里,Read Operation 才真正被触发。
Store 本身仍然不会解释技术性的 Resource Lifecycle。它只是把 Read Intent 编排到其 Infrastructure Dependency 上一个命名明确的 Operation。
这正是我们希望得到的职责分配:
private httpResource → ArticleResource → Source Signals und reload() → Read Store → ViewModelStore 继续决定这个 Slice 对外暴露哪些 Signal,以及如何从中生成 ViewModel。
但它不再逐个判断 Angular Resource 当前能否被安全读取。
这样 Read Flow 会保持安静:
article → ViewModelloadRequested → reload()阅读 Store 时,真正重要的是编排。
而不是底层 Framework API 的使用说明书。
8. Application:Facade 发送 Intent
Section titled “8. Application:Facade 发送 Intent”Delete Facade 不执行 HTTP。
它不知道 HttpClient。
也不知道 Resource。
它只发布 Intent。
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 }); };}这就是 Delete UI 的公共 API:
deleteArticle(articleId)Facade 不会删除 Article。
它只是发布“希望删除 Article”的 Intent。
Presentation → facade.deleteArticle(articleId) → articleDeleteEvents.deleteRequested({ articleId })之后发生什么,已经不再属于 Presentation 的职责。
9. Presentation:Button 发送破坏性 Intent
Section titled “9. Presentation:Button 发送破坏性 Intent”Template 保持很小。
<button type="button" class="article-card__delete" (click)="facade.deleteArticle(article.id)">Artikel löschen</button>这个例子刻意没有展示 Confirm Dialog。
不是因为 Confirm 不重要。
而是因为 Confirm 是发生在 Command 之前的一项 UI 安全决策。
本文展示的 Slice 从这个决定之后才开始:
Benutzer will löschen → deleteRequested({ articleId })真实应用里,我几乎总会为破坏性操作增加保护:Confirm、Undo、Soft Delete,或者按角色控制的权限。
但这些安全机制不应该遮蔽 Command Flow 本身。
Button 这里只调用 Facade。
Facade 发送 Intent。
其余全部进入 Event Flow。
10. Provider 与 Lifecycle
Section titled “10. Provider 与 Lifecycle”在这篇文章里,可以把整个 Slice 局部提供在 Page 或 Route 上。
providers: [ArticleResource, ArticleCommand, ArticleStore, ArticleCommandStore, ArticleDeleteFacade];真实应用中,我通常更倾向把这些 Provider 挂在 Route 上。
但对文章来说,局部 Variante 更有帮助,因为整个 Slice 的 Scope 会直接可见。
重要的是:
ArticleResourceArticleCommandArticleStoreArticleCommandStoreArticleDeleteFacade共同构成这个 Delete Slice 的 Scope。
ArticleResource 封装私有的 httpResource。
ArticleStore 消费它的 Source Signal,并编排 Read Flow。
ArticleCommandStore 也明确属于这个 Scope。
但这只解决了 Scope 的问题。
真实应用中,还必须确保 Command Store 在这个 Slice 的 Lifecycle 中确实被实例化一次,否则它的 Event Handler 不会激活。
这是这种方式一个小小的机械摩擦。
根据项目,可以在 Route、一个有意注入的 Slice Service,或者其他 Lifecycle 位置完成。
本文刻意不展示类似 inject(ArticleCommandStore) 这种只为实例化而存在的隐藏 Constructor Hack。
那样也许能让代码跑起来,但会隐藏依赖。
对本文更重要的是:
Command Store existiert im Slice-Scope → hört auf deleteRequested → feuert deleteSucceeded / deleteFailed具体如何在项目里接上这个 Lifecycle,是 Framework Mechanic。
业务边界并不会因此改变。
11. Delete、Create 与共享 Write 反应
Section titled “11. Delete、Create 与共享 Write 反应”真实 Feature 中,Delete 很少单独存在。
Create、Update 和 Delete 经常共享同样的后续反应。
例如:
createSucceededupdateSucceededdeleteSucceeded → loadRequestedCommand Store 可以统一处理这些 Success Event:
reloadOnWriteSucceeded$: events .on( articleCreateEvents.createSucceeded, articleUpdateEvents.updateSucceeded, articleDeleteEvents.deleteSucceeded, ) .pipe( map(() => articleReadEvents.loadRequested()), ),这比三个 Component 里分别写三个 Reload 调用更强。
Reload 不依赖 Button。
也不依赖具体 Command。
它依赖“Write 成功”这个结果。
Write succeeded → Read neu anfordern这才是真正的抽象。
12. Error 同样是一个结果
Section titled “12. Error 同样是一个结果”错误也不应该简单终结在 Component 的局部 catchError 中。
Error 同样是 Command 的一个结果。
deleteRequested → DELETE → deleteFailed({ articleId, error })之后发生什么,再次是一个独立反应:
deleteFailed → notificationEvents.showError(...)
deleteFailed → // keep row visible
deleteFailed → // rollback optimistic state对于本文来说,保留这个 Event 就够了。
真实 UI 可以以后响应 deleteFailed:显示 Toast、保持 Dialog 打开,或者回滚 Optimistic UI State。
重要的是:
Command Store 不决定如何展示错误。
它只发布:
Delete 失败了。
13. 为什么这不是 Overengineering
Section titled “13. 为什么这不是 Overengineering”对于单个 Button,这种切分看起来确实会多一些代码。
没错。
直接调用 http.delete() 更短。
但更短并不自动等于更简单。
直接调用之所以显得短,往往只是因为它把耦合藏起来了。
Button kennt DeleteButton kennt HTTPButton kennt ReloadButton kennt ToastButton kennt FehlerbehandlungEvent-driven Slice 会把这些过渡显式展示出来。
Intent → Command → Result → Reaction不是每个小 Button 都需要这样做。
但只要 Delete 会产生不止一个局部后果,这种切分就开始有价值。
例如:
- 重新加载列表;
- 离开详情页;
- 显示 Toast;
- 关闭 Dialog;
- 使 Cache 失效;
- 更新多个 Read Model;
- 回滚 Optimistic State。
这时 Event 不是学术性的绕路。
它是在解耦。
Delete 不是 Component 里一次快捷 HTTP 调用。
Delete 是一个破坏性的 Command。
UI 不应该自己编排这个 Intent 的完整执行过程。
它只发送:
deleteArticle(articleId) → deleteRequested({ articleId })Command Store 执行 Write。
Infrastructure 与 API 通信。
Success 和 Error 以 Events 的形式显式出现。
Reload 和 Notification 不依赖 Button。
它们依赖执行结果。
deleteRequested → ArticleCommand.deleteArticle(command) → deleteSucceeded | deleteFailed
deleteSucceeded → articleReadEvents.loadRequested() → notificationEvents.showSuccess(...)
deleteFailed → notificationEvents.showError(...)这样,破坏性操作会保持显式。
Read Store 继续只做 Read Store。
Infrastructure 自己封装技术 Resource。
Read Store 只编排 Source Signal、Projection 和 Read Intent。
Command Store 继续只做 Command Store。
而 Component 不会变成 Delete、Reload、Navigation 与 Toast 粘在一起的地方。