跳转到内容

Create Slice:把写入建模为 Command Flow

加载数据是 Read 问题。

创建数据是 Command 问题。

听起来只是一个很小的区别。

其实不是。

Retrieve Slice 回答的是:

UI 应该显示什么?

Create Slice 回答的是另一个问题:

用户向系统发出了什么业务意图?

因此这篇文章不会把 Create 写成“Component 里发一个 POST”。

也不会写成“给同一个 Store 再加几种 Method 就行”。

Create Flow 有很多建模方式:

  • Facade 直接调用 Service
  • Store Method create()
  • Command Store
  • 经典 Effect
  • Optimistic Update
  • 带 Submit Status 的 Form State
  • Event-driven Projection

其中一些方式以后会单独讨论。

本文有意聚焦一个 Event-driven Create Slice。

Submit 不直接保存。

Submit 只表达一个 Intent。


Create 不是 Component 里的 POST。Create 是 Intent → Command → Result → Reaction。

最重要的一点:

Create 不是换了 HTTP Method 的 Retrieve。

Retrieve 读取外部数据并形成 ViewModel。

Create 把一个意图发送进系统。

这个意图可能成功。

也可能失败。

而 Success 与 Failure 之后,都可能有多个彼此独立的 Reaction。

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

这是与下面完全不同的切分:

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

在直接调用链里,每一步都知道太多后续步骤。

在 Event-driven Slice 中,Intent、Execution 与 Reaction 被拆开。


为什么不把所有东西都放进一个 Store?

Section titled “为什么不把所有东西都放进一个 Store?”

最自然的做法经常是先造一个大 Store。

它加载列表。

保存选中项。

了解 Form State。

验证输入。

发送 POST。

处理 Error。

更新列表。

可能还顺便导航回去。

最后这个东西虽然还叫 ArticleStore,实际上已经成了一个小型 God Object。

它什么都知道。

什么都能做。

哪里都需要它。

一开始确实很方便。

但它混合了两种非常不同的模型:

  • Read Model:UI 要展示什么?
  • Command Model:系统要执行什么意图?

这种区分并不学术。

Read Model 可以为了消费方便而去规范化、靠近 UI。

Command 应该保持小、明确,并围绕 Intent 建模。

如果两者进入同一个 Store,Store 会同时向两个方向膨胀:既是 Projection Model,又是 Command Center。

所以这个 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

Read Store 不是“顺便也处理 Command”的地方。

Command Store 也不是生成 ViewModel 的地方。


NgRx Signal Store 带来的一点机械摩擦

Section titled “NgRx Signal Store 带来的一点机械摩擦”

这里确实会与 NgRx Signal Store 的机制产生一点摩擦。

从架构上,我希望分离 Read Model 与 Command Model。

但 NgRx Signal Store 基于 DI:只有在相关 Scope 中被 Provider 提供并真正 Inject 后,Store 才会存在。

对 Read Model 来说很自然。

Page 需要数据,于是 Inject 它的 Facade 或 Store。

独立 Command Store 就没这么自然。

因为它通常不会直接被 Render。它只需要接收 Intent、执行 Side Effect,然后产生 Event。

但它仍然必须被实例化。

以 NgRx Signal Store 21 为例:Read Store 与 Command Store 分离后,需要有意识地让 Command Store 在 Slice Scope 中至少实例化一次。

例如:

  • Page Provider
  • Route Provider
  • 由 Facade Inject Command Store
  • 一个小型 Bridge Service

这不是什么大问题。

但不应该把它藏起来。

这里的清晰架构确实需要一个小小的机械决定。

NgRx 把 Events Plugin 描述为 SignalStore 的 Event-based State Management Layer。NgRx 21 中,Events Plugin 的 withEffects 还更名为 withEventHandlers。这与本 Slice 很契合,因为 Write 并不是直接 Store Method,而是一个 Event Flow。


Retrieve 文章httpResource 放在 Infrastructure,因为它承载 HTTP 与 API Semantics。

Create 的边界更加明确。

Angular Resource API 面向异步 Read Dependency。Angular 文档也明确说明 resource 面向 Read Operation,而不是 Mutation,因为在 Dependency 变化或 Destroy 时,进行中的 Load 可能被取消。httpResource 又是 HttpClient 的 Wrapper,会把 Request Status 与 Response 暴露为 Signal,并经过包含 Interceptor 在内的 Angular HTTP Stack。

因此本文不会用 resource() 来执行 Write。

Write 不是派生出来的 Read State。

Write 是 Command。

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

对本文来说就是:

  • Read 侧:ArticleResource 内部的 Private httpResource
  • Write 侧:ArticleCommand 中的 HttpClient
  • 两者连接:Event

具体 HttpResourceRef 不会离开 Infrastructure。

hasValue() 与对 value() 的安全访问也留在那里。

这些不是 Read Store 的业务判断,而是 Angular Resource 的技术 Lifecycle Semantics。

Read Store 之后只消费命名清晰的 Signal 与显式 reload() Operation。


继续使用 Lorem API。

Retrieve 文章读取的是:

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

这个 Create Slice 写入 Create URL:

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

真实系统里的 API 是否完全这样返回,对这个 Slice 并不重要。

重要的是边界:

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

一种可能的 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

第一眼看上去,比 Retrieve Slice 多了不少东西。

但每个文件都有明确职责:

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

这些额外结构的目的,是避免一个 Store 必须了解所有事情。


Command 不是 DTO。

Command 不是 ViewModel。

Command 也不是 Form Model。

Command 描述从 UI 进入系统的意图。

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

对本文来说,这就够了。

没有 Validation。

没有 Metadata。

没有 UI Flag。

Command Model 刻意保持很小。

CreateArticleCommand
= Benutzer möchte einen Artikel erstellen

DTO 会在之后的 API Boundary 才产生。

不是这里。


现在定义 Create Slice 的 Event。

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

重点不是 Syntax。

重点是语言:

createRequested
createSucceeded
createFailed

createRequested 还不是结果。

它只是 Intent。

createSucceededcreateFailed 才是 Execution Result。

这项区分非常有价值。

因为 Reaction 不应该挂在 Submit 本身。

它应该挂在 Result 上。


3. Read Event:把 Reload 表达成独立 Intent

Section titled “3. Read Event:把 Reload 表达成独立 Intent”

Command Store 不应该直接了解 Read Store。

它不应该说:

articleStore.reload()

那又会形成直接耦合。

所以使用一个 Read Event:

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

这看起来也许只是多了一层很小的间接性。

但这层间接性正是边界:

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

Write 侧不会告诉 Read 侧应该怎样加载。

它只表达:

这次 Write 成功后,已经读取的 Article 数据应该重新请求。


4. Infrastructure:Command → DTO → POST

Section titled “4. Infrastructure:Command → DTO → POST”

到进入 Infrastructure 之前,我们都使用 Command。

只有到 API Boundary 才产生 DTO。

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

DTO 描述外部 Contract。

不是内部语言。

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

这里的 Mapping 很无聊。

很好。

但它仍然明确了正确位置。

如果未来外部 API 需要不同 Field Name、额外 Wrapper 或技术 Metadata,都在这个 Boundary 处理。

不在 Component。

不在 Facade。

也不在 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));
}

这个 Slice 的 Infrastructure 中有一个 article.command.ts

它集中定义 Command Store 可以执行的 Write Operation。

这里有意 1:1 切分:

ArticleCommandStore
→ nutzt ArticleCommand

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

Infrastructure 决定这个 Write 怎样转换为针对外部 API 的调用。

Payload Model 保留在 State Slice:

CreateArticleCommand
→ fachliche Absicht

DTO 直到 API Boundary 才产生:

CreateArticleCommand
→ toCreateArticleDto()
→ POST

5. Command Store:执行 requested,发布 Result

Section titled “5. Command Store:执行 requested,发布 Result”

现在进入 Command Store。

它不保存 ViewModel。

不负责 Render。

它监听 Event,调用 Infrastructure,然后发布 Result Event。

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

Store 做三件事:

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

之后,它把 createSucceeded 转换成 Read Event:

createSucceeded
→ loadRequested

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

Command Store 不知道 Resource。

不知道 ViewModel。

也不知道 Presentation。

它只知道 Event。

代码中其他 Success Reaction 有意只用未来 Event 表示。

Navigation 与 Notification 也不必在 Command Store 内直接作为 Imperative Side Effect 执行。

它们同样可以先被描述为 Event:

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

这样 Command Store 既不认识 Toast Service,也不认识 Router。

它只描述一次成功 Create 之后,业务或 UI 层面应该发生什么。

Slice 或应用其他部分可以消费这些 Event,并真正执行 Side Effect。

因此 tap 只保留给真正到达 Imperative Boundary 的地方:Router、Toast Service、Logging 或外部 API。

只要 Reaction 仍然可以被描述成 Event,map 通常更干净。


Create Flow 中 Double Submit 是真实问题。

用户点两次。

Form Submit 两次。

Browser 很慢。

Network 卡住。

所以 Flattening Operator 的选择并不是细节。

本文使用 exhaustMap

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

对 Create Command 来说,switchMap 往往危险。

Write 不是 Search Request。

一旦 Write 已经开始,我通常不希望只因为第二次 Submit 到来,就静默取消第一笔 Write。

对这个简单 Slice 来说,exhaustMap 是更保守的选择。


Retrieve 文章已经引入 Read Store。

Create 只需要再加一个 Event Handler。

具体 Angular Resource 仍然完全留在 Infrastructure。

ArticleResource 私有持有 httpResource,只向外提供一组很小、命名明确的 API:

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

这样技术 Guard 也留在正确位置。

hasValue() 保护对 value() 的访问。

这不是 Store 的业务分支判断。

它是 Angular Resource 的 Framework Semantics,因此属于拥有该 Resource 的 Infrastructure Adapter。

Read Store 之后不再了解:

  • HttpResourceRef
  • hasValue()
  • value() 的抛错行为
  • ResourceStatus
  • Parse 或 Request Detail

它只编排 Source Signal、ViewModel Projection 与 Read Intent。

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 自身保持严格有效。

不会为了让 Store 少写一个 Condition,就创造一个人为的空 Article。

只要 Article 存在,它就必须有效。

“当前没有可读 Value”在 Infrastructure Boundary 被统一表达为 null

Store 只继续投影这个 Source。

这就是 Reload Handling 的中心点。

不是这样:

Command Store
→ articleStore.reload()

而是这样:

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

Write 侧只发出一个 Read Intent。

Read 侧决定这个 Intent 对应哪种 Source Operation。

但它不会解释技术 Resource Lifecycle。

它只编排:

loadRequested
→ ArticleResource.reload()
article
→ ArticleVm

这里使用 tap 是合理的。

不是因为 Store 自己执行 HTTP 或 Resource Logic。

而是因为 Event Flow 在这里真正触发 Infrastructure Dependency 的显式 Operation。

在此之前,Event 只是映射成另一个 Event。

到这里才真正启动 Read Operation。

这样 Store 的阅读路径仍然很安静。

阅读它时关心的是:

  • 消费哪个 Source
  • 产生哪个 ViewModel
  • 哪个 Intent 触发哪个 Operation

而不是 httpResource 的使用说明。

如果未来多个 Read Store 都需要响应同一个 Write Success,它们也不必全部被 Command Store 直接认识。

每个 Store 可以自己监听 Event。


Create Facade 不执行 POST。

它不认识 HttpClient

也不会直接调用 Command Store。

它只发布 Intent。

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

Facade 使用 injectDispatch(articleCreateEvents)

因此它得到一组只针对这一个 Event Group 的小型 Typed Dispatch API。

它不需要了解 Generic Dispatcher,也不需要手动拼 Event。

Constructor 中有意 Inject 两个 Store,仅作为 Lifecycle Dependency。

这样 Read Store 与 Command Store 的 Event Handler 才会在 Slice Scope 中存在。

Facade 不调用 Store Method。

Create Page 的公开 API 只有:

createArticle(command)

Facade 不创建 Article。

它发布“希望创建 Article”的 Intent。

这只是很小的语言变化,却会带来很大的结构影响。

因为 Submit 不再直接与所有后续 Action 绑定。

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

之后发生什么,不再属于 Presentation。


8. Presentation:让 Signal Form 保持轻薄

Section titled “8. Presentation:让 Signal Form 保持轻薄”

输入部分使用 Signal Forms。

但刻意只使用很薄的一层。

Signal Forms 在这里不是架构。

它只是 Input Layer。

Angular 把 Signal Forms 描述为基于 Signal 管理 Angular Form State 的方式。本文只使用其中最简单部分:一个小 Model、一个 Form Binding、一次 Submit。Validation、Submit Status、Schema Validation 和复杂 Form Interaction 都先不展开。

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

Provider Boundary 在这里有意直接放在 Page 上展示。

真实应用中,我通常会更倾向于 Route Provider。

但文章中放在本地更容易看出 Slice 是闭合的。

重要的是:

ArticleResource
ArticleCommand
ArticleStore
ArticleCommandStore
ArticleCreateFacade

共同形成这个 Create Slice 的 Scope。

ArticleResource 封装 Private httpResource

ArticleStore 消费它的 Source Signal 并编排 Read Flow。

ArticleCommandStore 也有意属于这个 Scope。

不过,这只是 Scope Decision。

真实应用中,Command Store 还必须在这个 Slice 的 Lifecycle 中真正实例化至少一次,它的 Event Handler 才会处于 Active 状态。

这是该方法的一点机械摩擦。

根据项目,可以通过 Route、明确 Inject 的 Slice Service 或其他 Lifecycle 位置完成。

本文故意不展示类似 inject(ArticleCommandStore) 这种只为强制实例化的隐藏 Constructor Hack。

那可能让代码跑起来,却会掩盖依赖。

文章更关心的是:

Command Store 存在于 Slice Scope → 监听 createRequested → 发出 createSucceeded / createFailed

具体 Lifecycle 在项目里怎样接线,是 Framework Mechanic。

业务切分不受它影响。


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

Template 故意非常普通。

没有 HTTP。

没有 Store。

没有 Event Dispatch。

没有 Success Logic。

没有 Reload Logic。

没有 Navigation。

Presentation 收集 Form Data,然后通过 ngSubmit 调用 Facade。

仅此而已。

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

这就是 Component 的全部工作。


Event-driven Slice 的真正优势,会在 Write 成功之后显现。

一次成功 Create 可能触发很多事情:

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

直接 Flow 很容易把这些全部堆在 Submit 后面。

最后会变成:

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

这已经不是 Create Slice。

这是 UI 中的 Orchestration Node。

在 Event-driven Flow 中,这些 Reaction 挂在 Result 上:

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

本文只实现 Reload。

Navigation 与 Notification 只保留为提示。

这样 Slice 保持小。

文章展示切分,不展示所有可能 Reaction。


Error 也不应该只是结束在 Component 里的某个 catchError

它同样是 Command Result。

createRequested
→ POST
→ createFailed(error)

之后发生什么,再次只是 Reaction:

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

本文有 Event 就足够。

真实 UI 以后可以监听 createFailed,显示 Toast 或保持 Dialog 打开。

关键是:

Command Store 不决定 Error 应该怎样显示。

它只发布:

Create 失败了。


对一个单独的小 Form 来说,这种切分确实会显得代码更多。

没错。

直接调用 Service 更短。

但短不等于简单。

直接 Service Call 往往只是因为把耦合藏起来,所以看起来短。

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

Event-driven Slice 把这些 Transition 明确展示出来。

Intent
→ Command
→ Result
→ Reaction

不是每个小 Form 都需要这种结构。

但一旦一个 Write 会产生多个局部之外的后果,它就开始有价值。

例如:

  • 重新加载列表
  • 更新 Detail Page
  • 触发 Navigation
  • 显示 Toast
  • 关闭 Dialog
  • Cache Invalidation
  • 更新多个 Read Model

此时 Event 不是学术绕路。

它是一种解耦。


Retrieve Slice 的方向是:

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

Create Slice 则是另一种方向:

Form
→ Command
→ DTO
→ POST
→ Event
→ Reaktion

这就是核心。

Read 与 Write 并不对称。

Read 为展示构建模型。

Write 向系统发送 Intent。

所以前端里也不应该默认把两者塞进同一个 Store。


Create 不是 Component 里的 POST。

Create 是 Intent。

Component 收集 Input。

Facade 发布 Intent。

Command Store 执行这个 Intent。

Infrastructure 与 API 通信。

Success 与 Error 变成明确 Event。

Read Model 通过自己的 Event 作出反应。

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

重点不在于额外写了多少代码。

重点是方向:

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

这样 Read Store 仍然只是 Read Store。

Infrastructure 自己封装技术 Resource。

Read Store 只编排 Source Signal、Projection 与 Read Intent。

Command Store 仍然只是 Command Store。

一个看似无害的 ArticleStore 也不会慢慢长成控制半个 Feature 的 God Object。