核心问题
什么应该被存下来?什么应该被算出来?什么必须用状态机管理?
在软件系统里,很多混乱来自把三类东西混在一起:
- Fact:已经发生的事实
- State:当前所处的状态
- Derived View:从事实和状态推导出来的视图或结论
如果这三类没有分清,系统会出现脏数据、一致性问题和无法解释的边界情况。
Fact:已经发生的事实
Fact 是已经发生、不可随便改写的事情。
例如课程平台里:
type PaymentSucceeded = {
paymentId: string
accountId: string
courseId: string
amount: number
occurredAt: Date
}
type CoursePurchased = {
purchaseId: string
accountId: string
courseId: string
paymentId: string
purchasedAt: Date
}
type CourseAssigned = {
organizationId: string
accountId: string
courseId: string
assignedBy: string
assignedAt: Date
}
事实的特点:
- 它描述已经发生的事。
- 它有时间点。
- 它通常不应该被覆盖,只能被后续事实修正。
- 它能回答“为什么系统现在是这样”。
例如退款不应该把购买记录物理删除,而应该增加一个新事实:
type PaymentRefunded = {
paymentId: string
refundedAmount: number
refundedAt: Date
reason: string
}
事实建模的优点是系统可解释。
当用户问:
为什么我现在不能看这门课?
系统可以回答:
你在 2026-06-01 购买了课程。
你在 2026-06-03 申请并完成退款。
退款后购买访问权被撤销。
如果只存一个 canViewCourse = false,系统就失去了历史解释能力。
State:当前状态
State 描述一个对象当前处在哪个阶段。
例如:
type Order = {
id: string
status: "pending" | "paid" | "refunded" | "canceled"
}
状态不是坏东西。很多系统必须有状态。
问题在于:
状态必须有明确的流转规则。
例如订单状态应该像这样:
pending -> paid
pending -> canceled
paid -> refunded
而不应该允许任意跳转:
refunded -> paid
canceled -> paid
paid -> pending
如果状态字段没有状态机,它就只是一个容易写坏的字符串。
Derived View:推导视图
Derived View 是从事实和状态推导出来的结论。
例如:
isCustomer
canAccessCourse
isActiveLearner
hasCompletedCourse
这些通常不应该作为核心事实直接存储。
例如:
user.isCustomer = true
这个字段的问题是:它依赖很多事实。
- 是否有成功支付?
- 是否已退款?
- 是否有有效订阅?
- 是否在企业合同覆盖范围内?
- 是否处于宽限期?
只存一个布尔值会把复杂规则压扁。
更好的做法是:
function isCustomer(accountId: string): boolean {
return hasPaidOrder(accountId) || hasActiveSubscription(accountId)
}
或者更业务化:
function canAccessCourse(accountId: string, courseId: string): boolean {
return getCourseAccessGrants(accountId, courseId).some(isValidNow)
}
三者的关系
可以这样理解:
Facts + State Machines -> Derived Views
例如:
PaymentSucceeded
CoursePurchased
PaymentRefunded
SubscriptionActivated
SubscriptionExpired
OrganizationAssignedCourse
+
Order.status
Subscription.status
Enrollment.status
->
canAccessCourse
isPayingCustomer
isActiveLearner
事实负责解释历史。
状态负责表达生命周期。
视图负责服务查询和业务判断。
什么时候存事实
适合存成事实的东西:
- 它已经发生。
- 它对审计、排查或业务解释重要。
- 它会影响后续规则。
- 它不应该被静默覆盖。
例如:
- 支付成功
- 退款完成
- 课程分配
- 课程完成
- 邀请发送
- 成员加入组织
- 权限被授予或撤销
什么时候存状态
适合存成状态的东西:
- 它有明确生命周期。
- 它当前只可能处于有限几个阶段之一。
- 它的流转需要被约束。
- 它经常被查询。
例如:
type SubscriptionStatus =
| "trialing"
| "active"
| "past_due"
| "canceled"
| "expired"
状态必须配状态流转规则:
function cancelSubscription(subscription: Subscription) {
if (!["trialing", "active", "past_due"].includes(subscription.status)) {
throw new Error("Subscription cannot be canceled from current state")
}
subscription.status = "canceled"
}
什么时候存派生值
派生值也不是绝对不能存。
可以存,但要承认它是缓存或投影视图。
适合存派生值的情况:
- 查询非常频繁。
- 推导成本很高。
- 可以接受短暂不一致。
- 有明确的重建机制。
- 有明确的来源事实。
例如:
type CourseProgressSummary = {
accountId: string
courseId: string
completedLessons: number
totalLessons: number
progressPercent: number
updatedAt: Date
}
这可以存,但它不是事实本身,而是投影。
如果它坏了,系统应该能从 lesson completion facts 重建:
LessonCompleted[] -> CourseProgressSummary
原则:
派生值可以存,但必须知道它从哪里来,以及坏了怎么重建。
状态字段的坏味道
1. 布尔值过多
type Course = {
isDraft: boolean
isPublished: boolean
isArchived: boolean
}
这会产生不合法组合:
isDraft = true
isPublished = true
isArchived = true
更好:
type CourseStatus = "draft" | "published" | "archived"
2. 状态名太含糊
status: "active"
active 是什么意思?
- 账号可登录?
- 订阅有效?
- 课程上架?
- 最近使用过?
状态名要绑定具体对象和语义:
AccountStatus
SubscriptionStatus
CoursePublicationStatus
EnrollmentStatus
3. 状态没有流转约束
如果任何地方都能写:
order.status = "refunded"
那状态机只存在于人的脑子里。
更好的做法是集中流转:
markOrderPaid(orderId)
refundOrder(orderId)
cancelOrder(orderId)
事件溯源不是唯一答案
事实建模不等于一定要做完整 Event Sourcing。
完整事件溯源意味着:
系统状态完全由事件日志重放得到。
这很强,但也很复杂。
大多数业务系统可以采用更朴素的组合:
核心表存当前状态
关键事实表/审计表记录重要事件
派生表用于查询性能
例如:
orders
payments
refunds
course_access_grants
course_progress_summary
audit_events
这比完整 Event Sourcing 简单,也比只存当前状态更可解释。
小结
- Fact 是已经发生的事实,负责解释历史。
- State 是当前生命周期阶段,必须有流转规则。
- Derived View 是从事实和状态推导出来的结论。
- 不要把结论当事实存。
- 派生值可以存,但要把它当缓存或投影,并保留重建路径。
- 状态字段必须避免布尔爆炸、含糊命名和无约束跳转。
- 事实建模不等于必须上完整事件溯源。