核心问题

什么应该被存下来?什么应该被算出来?什么必须用状态机管理?

在软件系统里,很多混乱来自把三类东西混在一起:

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

事实的特点:

  1. 它描述已经发生的事。
  2. 它有时间点。
  3. 它通常不应该被覆盖,只能被后续事实修正。
  4. 它能回答“为什么系统现在是这样”。

例如退款不应该把购买记录物理删除,而应该增加一个新事实:

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

事实负责解释历史。

状态负责表达生命周期。

视图负责服务查询和业务判断。

什么时候存事实

适合存成事实的东西:

  1. 它已经发生。
  2. 它对审计、排查或业务解释重要。
  3. 它会影响后续规则。
  4. 它不应该被静默覆盖。

例如:

  • 支付成功
  • 退款完成
  • 课程分配
  • 课程完成
  • 邀请发送
  • 成员加入组织
  • 权限被授予或撤销

什么时候存状态

适合存成状态的东西:

  1. 它有明确生命周期。
  2. 它当前只可能处于有限几个阶段之一。
  3. 它的流转需要被约束。
  4. 它经常被查询。

例如:

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

什么时候存派生值

派生值也不是绝对不能存。

可以存,但要承认它是缓存或投影视图。

适合存派生值的情况:

  1. 查询非常频繁。
  2. 推导成本很高。
  3. 可以接受短暂不一致。
  4. 有明确的重建机制。
  5. 有明确的来源事实。

例如:

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 简单,也比只存当前状态更可解释。

小结

  1. Fact 是已经发生的事实,负责解释历史。
  2. State 是当前生命周期阶段,必须有流转规则。
  3. Derived View 是从事实和状态推导出来的结论。
  4. 不要把结论当事实存。
  5. 派生值可以存,但要把它当缓存或投影,并保留重建路径。
  6. 状态字段必须避免布尔爆炸、含糊命名和无约束跳转。
  7. 事实建模不等于必须上完整事件溯源。