跳转到内容

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 / notification

Button 不负责删除。

Button 只发送一个破坏性的 Intent。


Button 不负责删除。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 weiter

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

Pattern 保持不变:

ArticleCommandStore
→ nutzt ArticleCommand

Store 决定什么时候执行 Write Operation。

Infrastructure 决定如何把这个 Write 翻译成外部 API 所需的形式。


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;
}>(),
},
});

同样,语言比语法更重要:

deleteRequested
deleteSucceeded
deleteFailed

deleteRequested 还不是结果。

它只是一个 Intent。

deleteSucceededdeleteFailed 才是执行后的结果。

我也会把 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-Zugriffe

Create 往往会在这里生成 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 entgegennehmen
2. DELETE über Infrastructure ausführen
3. deleteSucceeded oder deleteFailed veröffentlichen

然后把 deleteSucceeded 转换成一个 Read Event:

deleteSucceeded
→ loadRequested

这里刻意不直接调用 Read Store。

Command Store 不认识 Resource。

不认识 ViewModel。

也不认识 Presentation。

它只认识 Events。


DELETE 调用只有两个可能结果:

success
→ deleteSucceeded
error
→ deleteFailed

mapResponse 很适合把这两个结果清楚表达出来。

成功分支生成 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

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。


具体的 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 | null

Domain 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
→ ViewModel

Store 继续决定这个 Slice 对外暴露哪些 Signal,以及如何从中生成 ViewModel。

但它不再逐个判断 Angular Resource 当前能否被安全读取。

这样 Read Flow 会保持安静:

article → ViewModel
loadRequested → reload()

阅读 Store 时,真正重要的是编排。

而不是底层 Framework API 的使用说明书。


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。


在这篇文章里,可以把整个 Slice 局部提供在 Page 或 Route 上。

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

真实应用中,我通常更倾向把这些 Provider 挂在 Route 上。

但对文章来说,局部 Variante 更有帮助,因为整个 Slice 的 Scope 会直接可见。

重要的是:

ArticleResource
ArticleCommand
ArticleStore
ArticleCommandStore
ArticleDeleteFacade

共同构成这个 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 经常共享同样的后续反应。

例如:

createSucceeded
updateSucceeded
deleteSucceeded
→ loadRequested

Command 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

这才是真正的抽象。


错误也不应该简单终结在 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 失败了。


对于单个 Button,这种切分看起来确实会多一些代码。

没错。

直接调用 http.delete() 更短。

但更短并不自动等于更简单。

直接调用之所以显得短,往往只是因为它把耦合藏起来了。

Button kennt Delete
Button kennt HTTP
Button kennt Reload
Button kennt Toast
Button kennt Fehlerbehandlung

Event-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 粘在一起的地方。