接手一个不熟悉的系统时,有一件事我几乎每次都会做:找一个核心的业务对象——订单、工单、审批单,随便哪个——然后打开三四个文件,试图在脑子里画出一张状态图。

不是从文档里画。文档如果有,多半已经过时了。是从代码里画。读 OrderService.cancel(),看到 if order.Status != Paid。读 OrderService.refund(),看到 if order.Status == Paid || order.Status == Delivered。读 OrderService.complete(),发现它根本没判断状态——是忘了,还是故意的?翻 git blame,三行 if 是三个不同的人在不同季度写的,各自单独看都合理,但拼在一起,没人能说清 paid 状态的订单到底有多少种合法的变化路径。

这件事花了半个小时。画出来的图大概长这样:5 个状态,七八个箭头,两个问号——因为有两个边界情况从代码里推不出来,原来的作者离职了。

这半个小时本来不应该花。这张图应该已经在代码里了。

我想聊的就是这件事:为什么大部分代码里的状态转换规则没有一个明确的家,以及把它显式化之后会发生什么。

不是坏习惯,是好习惯过了保质期

说说为什么我们默认用 enum + if/else 管理状态。不是因为懒,也不是水平不够——是这种做法在起点上完全合理。

一个业务对象刚开始的时候,状态很少。订单:pending → paid → shipped。三个状态,两个操作。这时候在每个操作前面加一个 if 判断状态,是最直接的写法。你甚至不会觉得这是"状态管理"——它就是几行判断,是操作的前置条件,跟"参数不能为空"是同一个性质。

问题不在这里。

问题在于业务状态有一个很常见的规律:它更容易增加,而不是减少。 今天三个状态,半年后五个,一年后七个。每次加新状态,你会很自然地在新操作前面加新的 if。每次都完全合理。没有人会在 sprint planning 里说"我们要重构状态管理"——除非已经出过事了。

但十个各自合理的局部决策叠加起来,可以产生一个不合理的全局结果。这不是设计失误,是一种组织层面的惯性。如果状态转换没有一个明确的家,它就会住到每个人的代码里,每个版本略有不同。

我觉得关键不是批判 if/else,而是意识到它有一个你不注意就会安静跨过的阈值。阈值的这边,if/else 是最佳选择。阈值的那边,它开始产生一种特殊的技术债务:不是代码写得烂,是设计知识丢失了。

隐式状态机衰退的三个方式

当状态转换规则完全靠 if/else 表达,代码里其实存在一个状态机——但它不是写出来的,是推导出来的。每次要回答"X 状态能不能执行 Y 操作",需要的不是看一个地方,而是看完所有地方然后自己做 AND。

这种隐式状态机有三个衰退机制。我观察到的,不一定全对。

第一个:分散。

同一个规则被反复表达,每次表达都可能不一致。还是订单的例子:取消操作判断 if order.Status != Paid,退款操作判断 if order.Status == Paid || order.Status == Delivered。两个判断都在说"订单处于什么状态时能做什么",但它们用的是互补的逻辑——一个是排除法,一个是枚举法。

两个月后,有人加了一个 PartiallyShipped 状态。他更新了退款判断(加了一个 || order.Status == PartiallyShipped),但没更新取消判断——因为他根本不知道取消那边也有一个状态检查,写在另一个文件里。

这不是代码审查能发现的问题。除非 reviewer 恰好记得"取消那边也有个判断"——但六个月后没人记得。

第二个:组合盲区。

N 个状态 × M 个操作 = N×M 种可能的组合。比如 6 状态 × 7 操作 = 42 种。其中合法的可能只有十来种,剩下三十多种应该被拒绝。

if/else 模式不会告诉你这三十多种非法组合是否都被覆盖了。你怎么检查?把所有操作的判断逻辑串起来审计一遍?没人做过这件事。我也没有。

更麻烦的是,有人可能为了防御性编程,在某个操作里不小心拒绝了一个其实合法的组合——"先判断一下,稳一点"。防御性编程的动机是好的,但当判断散落在各处,你没法区分"这个限制是业务规则还是程序员自己加的"。

第三个:假设腐烂。

每个 if 背后都有一个隐性假设。if status == A || status == B 的假设是"只有 A 和 B 能执行此操作"。这个假设在写下来的当天是对的。

但假设没有保质期。当新状态 C 出现、新操作出现、业务规则调整,旧的 if 不会自己更新。代码不像文档——文档你可以说它过时了,代码一直在跑,看起来像是对的。

这就是这类技术债务阴险的地方:不是"代码坏了",是"代码还在跑,但它的假设已经不是真的了"。等你发现,通常不是通过审查,是通过一个生产 bug。

给转换规则一个家

前面说了很多问题,现在聊聊我觉得更干净的做法。

核心思想很简单:把状态转换规则当成一个独立的东西,而不是每个操作的附属品。

在代码层面,这件事不需要框架,不需要库,甚至不需要设计模式。一张表就够了。Go 里是一个 map[State]map[Event]State,Java 里是一个 Map<State, Map<Event, State>>,Python 里是一个嵌套 dict。选什么语言无所谓,重要的是这个结构本身:

type State string
type Event string

var transitions = map[State]map[Event]State{
    "pending":   {"pay": "paid", "cancel": "canceled"},
    "paid":      {"ship": "shipped", "refund": "refunding"},
    "shipped":   {"deliver": "delivered"},
    "delivered": {"refund": "refunding"},
    "refunding": {"complete_refund": "refunded"},
    "canceled":  {},
    "refunded":  {},
}

func (s State) Next(e Event) (State, error) {
    if nextStates, ok := transitions[s]; ok {
        if next, ok := nextStates[e]; ok {
            return next, nil
        }
        return s, fmt.Errorf("illegal transition: %s -[%s]→ ???", s, e)
    }
    return s, fmt.Errorf("unknown state: %s", s)
}

不是想说这 30 行代码有什么神奇。它的价值不在代码本身——在于你脑子里的东西被外化了。

外化之前,状态转换规则是一个运行时的涌现行为:你跑一遍代码,看它做了什么,推断规则是什么。外化之后,它变成了一个静态的设计产物:你可以一行一行读,可以 review,可以拿给产品经理核对——"你看,当前业务规则下,pending 只能被 pay 和 cancel,对不对?"

这张表就是我前面说的"转换规则的家"。它不需要很大,不需要很复杂。但它有一个关键属性:没声明的就是不合法。 不需要在每个操作里写防御性 if,不需要靠 else 兜底。表是你对"什么是合法转换"这个问题的完整回答。回答之外,一概拒绝。

还有一个小事我觉得值得提:这张表本身是最好的业务文档。新人入职想知道"订单的生命周期是什么样的"——看表。半年后你自己回来改这段代码——看表。表不会撒谎,不会过时,因为代码就是在按它执行。文档和实现之间的 gap 消失了。

顺带捡到的几样东西

给转换规则一个集中的入口之后,有些之前很难做的事变得几乎免费。不是设计目标,属于顺带捡的。

状态变化日志。 谁在什么时间把什么从 A 变成了 B,原因是什么事件。在 if/else 模式下,你需要在每个操作里加日志,总有人忘。现在在一个地方包一层就全有了——而且事件本身就是"为什么",日志自动带上了业务语义。

副作用管理。 一个状态变化可能需要发通知、刷新缓存、更新搜索索引。散落在 handler 里的时候,这些副作用是"顺便做一下"——有时候做了,有时候没做,取决于你在哪个 handler 里。有了集中入口,副作用可以按转换路径注册,和业务逻辑解耦:

var sideEffects = map[struct{ From, To State }]func(*Order) error{
    {"paid", "shipped"}:  notifyCustomer,
    {"shipped", "delivered"}: updateInventory,
}

幂等。 同一个事件发两次,"已支付"收到第二次"支付"——在转换表里,"paid"状态没有"pay"这个事件的出口,直接返回 error。不需要每个 handler 里写防重逻辑。

我个人的感受是:这些横切关注点之所以散落,根源就是没有一个统一的"状态变化发生地"。一旦有了,它们就有地方可待了。反过来想,你之所以在 if/else 模式下做不好这些事,不一定是你不够小心——是这个结构不支持你做好。

什么时候应该收住

工具好用的一个副作用是:你想到处用。但设计判断力的重要一部分,是有能力说"这里不需要"。

显式状态机值得做的场景,我自己的判断标准是这样:状态之间是否存在业务上的"非法转换"。 如果所有状态到所有状态都是合法的(比如用户的"个人简介"字段——什么值变什么值都可以),那状态机没有意义。如果有明确的业务规则说"A 不能直接变成 B",那这个规则应该活在代码里而不是注释里。

但这不算一个精确的标准。聊几个具体场景。

状态少又稳定,if/else 就够了。 比如一个 feature flag 的 on/off。两个状态,不会有第三个。加一张转换表属于过度设计——跟一个 bool 过不去没必要。

合法性依赖大量动态数据。 比如"用户能不能提现"取决于余额、KYC 状态、风控结果十几个条件。这时候合法性判断本身就是一个复杂的决策过程,塞进转换表反而不对。转换表擅长回答"什么状态变化在业务规则上是允许的",不擅长回答"在所有这些条件下是否允许"。后者更适合在 Next 外面单独做。

中间地带。 大部分实际场景其实在这个地带——不是绝对的简单,也不是绝对的复杂。我个人倾向于偏显式化一侧。原因比较朴素:业务状态只增不减,今天 3 个状态觉得 if/else 挺好,半年后涨到 5 个再过一年 8 个——迁移成本不是线性的。你迁移的不只是代码,是散落各处的隐性假设。有些假设你甚至不知道自己有,直到迁移时它们变成 bug。

但这只是我的偏好,不是规律。也许你们团队的代码组织方式不同,也许你们的业务状态天生稳定。我聊这些只是想提供一个思考角度,不是验收标准。

那张不在的图

回到开头接手代码的场景。

那半个小时花在反向工程状态图上。缺少的不是代码——代码充足得很,每个 handler 都有判断。缺少的是一个被承认的设计决策。

如果在系统还简单的时候,有人坐下来花十分钟,把"这个对象能怎么变"写成一张表,放在代码里——那些后来各自添加的 if,就会有一个可以对照的东西。新加一个操作?先看表,表里没有的路径就是讨论点:是该拒绝,还是该更新设计?

没有人能在写第一个 if 的时候就预见到六个月后这个对象会有七八个状态。但有人可以把"转换规则应该有一个家"这件事做成设计习惯,让后来的变化在受控的环境里发生。

代码写错了你的数据不一定脏——但如果核心业务状态机上有个洞,数据进入非法状态的概率会显著上升。

每一个散落着 if/else 的业务对象,都在维护一个隐式的状态机。那张表没有被写出来,不是因为它不重要——是因为它太明显了,明显到所有人都觉得"不用专门写,看看代码就懂了"。

然后看代码的人离职了。

一个简单状态机,比很多"企业级架构"对系统正确性的贡献更大。