跳转到内容

+State——业务逻辑所在的地方

Infrastructure 文章 已经把一个原始的 "ACTIVE" 转换成了自己的 CustomerStatus.Active

但也仅此而已。

我们仍然不知道这个客户是否允许下单,不知道当前有哪些操作可用,不知道哪个 State Transition 合法,也不知道某个业务流程真正依赖哪项 Selection。

这里开始进入 +State

Infrastructure 把外部世界转换成一个受控的内部表示。+State 则把这些值放进自己的业务上下文中,建立关系并赋予含义。

一个 +State 一开始往往看起来很普通:

type CustomerState = {
customers: Customer[];
selectedCustomerId?: CustomerId;
loading: boolean;
};

单独看,这个 State 只是保存了一些值。真正有意思的是,这些值能够回答什么问题:

当前选中了哪个客户?
这个 Selection 还有效吗?
哪些客户满足某项条件?
当前允许哪些操作?
一次 State Change 会带来什么结果?

这些问题并不全是 Business Rule。Store 中的每个字段也不一定都是业务 State。比如 loading 仍然属于技术运行时状态——即使 Loading 不是 Boolean 已经说明,它通常也不是一个简单开关。

真正的区别在于,+State 不会停留在“保存值”。State Transition、派生和基于当前应用状态的业务决策也在这里产生。

State 通过关系、规则和派生获得业务意义。

+State 把 State 与 Business Rule、派生和 State Transition 连接起来,并从中产生具有业务意义的结果。

一种很简单的 State Management 理解是:

API
Store 保存 Response
Component 读取 Response

这在某些场景完全够用。但从架构上看,这时的 Store 几乎只是 Backend 数据的 Cache:保存值,却没有为它们建立自己的业务含义。

我们对 +State 的理解更进一步:

Infrastructure
+State
├── 拥有 State
├── 处理业务 Intent
├── 应用 Business Rule
├── 建模 State Transition
└── 产生派生

具体使用 Signals、NgRx、Redux、Zustand、RxJS 还是其他 State 技术,并不会改变这项职责。

Library 决定怎样管理 State。架构决定这个 State 承担什么职责

Infrastructure 已经完成了转换:

"ACTIVE"
CustomerStatus.Active

现在,+State 可以把这个状态与其他信息一起解释:

CustomerStatus.Active
+
CreditHold.None
+
Account.enabled
canOrder

“这个客户是否允许下单”已经不是技术转换,而是系统自己的业务规则。

同样的还有:

  • 一个订单允许取消吗?
  • 一个数据记录允许删除吗?
  • 某个 State Transition 合法吗?
  • 当前业务状态下可以执行哪些 Action?
  • 某个 Entity 是否满足特定业务条件?

一条 Business Rule 不会因为它最后只是控制 Button 是否 disabled,就突然变成表现层逻辑。

但这类规则最容易偷偷出现在这里:

disabled = customer.status !== CustomerStatus.Active || customer.creditHold !== CreditHold.None;

只有一个 UI 时,这看起来没什么问题。

一旦同样的决定还要在 Ionic 表现层、移动端 UI 或其他地方使用,就会出现同一规则的多份实现。

更好的方式是由业务 State 直接提供明确结论:

canOrder;

表现层随后只决定如何展示这个结果:禁用 Button、把 Action 灰掉,或者干脆不显示 Menu Item。

但“业务上是否允许”不应该再由表现层重新判断。

并不是所有信息都需要额外保存。

从:

customers
+
selectedCustomerId
selectedCustomer

可以直接派生出 selectedCustomer

同样:

customers
+
filter
+
sort
visibleCustomers

selectedCustomervisibleCustomers 都不需要作为 Source State 的第二份拷贝保存,也不需要每次变化时手动同步。

它们可以由现有 State 计算出来。

这样可以减少重复事实以及随之而来的同步问题。一旦两个被保存的值表达同一份信息,就必须有机制永久保证它们一致。派生状态没有这个问题。

从 customers、Selection、Filter 和 Sort 等 Source State 中,可以派生出 selectedCustomer、visibleCustomers 和 canOrder。

响应式 Filter 与 Sort 会更详细讨论 Query State 与 Derived State 的区别,以及为什么 Filter 和 Sort 自身也可能属于 State。

一个 Selection 也不意味着必须把完整 Entity 再保存一次。

entities
+
selectedId
selectedEntity

如果 Selection 对业务或应用级状态有意义,保存 ID 作为 Source State 就可能足够。选中的 Entity 再由它派生出来。

但 Selection 是否应该进入 +State,仍然取决于含义。表格里临时高亮的一行可能只是表现层状态。一个后续业务操作会依赖的 Selection,则拥有不同生命周期和职责。

Select by ID 会展示这样的 Select Flow 可以如何具体切分。

派生并不局限于一个 Source。

articles
+
authors
+
categories
投影

多个 State Source 可以共同形成一个可消费的投影。

关键不在于一共涉及几个 Store 或 Source,而在于最终产生的信息由谁负责。

来自多个数据源的 ViewModel 会展示多个响应式 Source 如何组合。Event-driven Projection 则讨论通过 Event 构建和更新的投影。

两种方式表达的是同一个核心思想:Consumer 不必知道底层 State 的原始结构。

不是所有东西都应该放进一个 Store

Section titled “不是所有东西都应该放进一个 Store”

前面的论证不应该被理解为:一个 Feature 必须只有一个巨大的 +State

Store Boundary 并不主要由文件大小决定,也不因为两个状态恰好使用相同 Entity 类型就自动应该合并。

更重要的问题包括:

  • 哪些职责真正属于一起?
  • 它们是否拥有相同生命周期?
  • 是否因为同样原因发生变化?
  • 谁拥有这些 State?
  • 哪些 Consumer 会使用它们?

两个 State 可以都使用 Customer,却承担完全不同的职责。

反过来,多个数据类型也可能共同构成同一个业务状态。

当 Read Store 变得太大 会详细讨论这些 Boundary。

+State 来说,最重要的是:这个 Layer 描述的是一种职责类型,不是“必须只有一个 Store”的规则。

+State 不只是拥有可读 State。业务上的修改意图也会在这里与当前 State 相遇。

deleteRequested
当前 State
允许 / 不允许
State Change

或者:

completeOrder
Business Rule
State Transition

Intent 首先只是一个意图。

“希望删除这个客户”与“这个客户已经被删除”不是一回事。

这项修改是否允许、最后应该形成什么业务状态,取决于当前 State 与规则。

CRUD Slices 系列 会具体展示 Create、Retrieve、Update 和 Delete Flow 如何沿着这些职责被切开。

对本篇来说,最重要的边界更简单:业务修改意图不应该等到 Component 或 HTTP Service 里才第一次获得含义。

+State 可以提供 State 和派生,后续 ViewModel 会以此为输入。

但并不是每个 View 的完整投影都必须直接在 Store 中构建完成。

一个投影可以先把业务 State 转成可消费的信息。Application 层再为某个具体 View 组合多个这样的信息,或者暴露一个更适合该 View 的 API。

这条边界具体放在哪里,取决于实际切分。

ViewModel Aggregation 会更详细说明 ViewModel 如何由多个数据 Source 形成,而不把 Backend Structure 或内部 State Detail 泄漏到表现层。

+State 来说,真正重要的是:Business Rule 与业务相关派生,不应该等到某个具体 UI 被构建时才出现。

+State 可以并且应该:

  • 拥有业务相关的 Runtime State
  • 应用 Business Rule
  • 建模业务 State Transition
  • 处理业务 Intent
  • 生成 Derived State
  • 提供相关 Selection
  • 生成业务 Filter 与 Projection
  • 响应业务相关 Event
  • 集中那些不依赖具体表现层的规则

+State 不应该:

  • 自己实现 HTTP Request
  • Parse DTO
  • 把外部 API Contract 直接穿过业务层
  • 直接使用 HttpClientfetch 或 SDK 专用 API
  • 解释技术 API Error Code
  • 直接控制 Router
  • 打开 Dialog
  • 显示 Snackbar 或 Toast
  • 了解 DOM 或 UI Framework 细节
  • 区分 Angular Material、Ionic 或 Bootstrap
  • 承担本应属于 Application 的具体 View Orchestration

遇到边界模糊时,可以问一个简单问题:

如果明天 Material 换成 Ionic,这条规则还会成立吗?

如果会,它通常是 +State 的好候选。

这不是严格定义,但在实践中非常好用。

当多个 UI 共同使用同一套业务能力时,这个切分的价值会特别明显。

在一个长期项目中,Angular Material 与 Ionic 曾经多年并行运行在同一业务基础上。

之所以能做到,是因为 Business Rule 没有分别在两个表现层里重复实现。

+State
/ \
/ \
Material Ionic

表现层决定如何展示。+State 提供业务结论。

如果 canOrder 在两个表现层里分别计算,就必须永久同步两份实现。迟早两个 UI 会对同一个业务问题给出不同答案。

同一个 +State Layer 为 Angular Material 和 Ionic 表现层提供相同的 Business Rule 与派生。

同样的分离还会带来其他好处。

Business Rule 变得可发现。如果一个业务决定分散在 Component、Pipe、Form Validator、Service 和 Template 中,就无法在一个地方完整理解,只能靠重新考古来拼出来。

如果团队知道 canOrder 来自业务 State,也就知道应该去哪里找规则。

派生保持一致,因为不需要手动同步同一信息的多个副本。

而 Business Rule 可以脱离具体 UI 单独测试。Material 最终 Render 一个 Button,还是 Ionic Render 一个 Menu Item,对 canOrder 本身毫无影响。

这些好处并不是某个 State Library 带来的。

它们来自边界。

给业务逻辑一个清晰归属,并不意味着所有变量都应该搬进 Store。

例如:

isDialogOpen
hoveredRow
activeTab
tooltipVisible
accordionExpanded

这些通常属于表现层 State。它们描述某个具体 UI 当前如何呈现。

当然,这条边界也不是绝对的。例如某个 activeTab 未来可能恰好代表一个业务相关 Selection。变量名本身并不能决定归属。

更有帮助的问题是:

这个 State 在脱离当前表现形式之后,仍然有意义吗?

如果答案是否定的,+State 大概率不是它自然的家。

+State 拥有业务相关 State、Business Rule、派生、Selection、State Transition 与业务 Intent。

但具体 View 仍然不应该被迫了解整个 State。

一个 Customer List View 也许只需要:

customers
loading
selectCustomer()
createCustomer()

Detail View 则需要同一业务领域的另一个切片。

这就形成下一条职责边界:+State 拥有并解释业务逻辑。Application 从中切出适合具体 Use Case 或具体 View 的 API。

这样既能让业务逻辑集中在一个清晰位置,也能避免每个表现层都理解其全部内部结构。