拒绝 if-else:基于 DDD 与状态机的订单业务
作为一名后端开发者,你一定看过这样的代码:比如你要写一个订单业务,在 OrderService 里写了一堆 if-else 来判断订单能不能发货、能不能退款。随着业务越来越复杂,这些 if 就像面条一样缠在一起,改一个地方崩三个地方,这种代码非常不健康。
我们要用 DDD(领域驱动设计) 的思想,配合 状态机模式,让你的业务代码更健壮。
背景
假如你有个订单业务,订单有以下几种状态:
- CREATED: 待支付
- PAID: 已下单,待接单
- ACCPETED: 已接单
- DELIVERED: 已送出
- COMPLETED: 已完成
- CANCELLED: 已取消
- REFUNDED: 已退款
用户事件:
- 支付: 把
CREATED变成PAID。 - 收货: 把
DELIVERED变成COMPLETED,或者把CREATED变成CANCELLED。 - 退款: 把 某一状态 变成
REFUNDED。
以前我们是怎么写的:
这是典型的“三层架构”写法。
Order.java (实体类,只有Getter/Setter)
1 | // 这就是所谓的“贫血模型”,它只是数据的搬运工,没有脑子 |
OrderServiceImpl.java (Service层的业务逻辑)
1 |
|
这种写法的问题:
- 逻辑分散:判断能不能支付、能不能收货的逻辑都在 Service 里,如果你的系统里有 5 个地方都能触发支付,你就要写 5 遍
if判断。 - 不安全:谁都可以调用
order.setStatus("随便啥"),数据很容易搞乱。如果不小心在“已取消”状态执行了“发货”,系统就会崩溃或产生脏数据。 - 难以维护和扩展:如果需要新增一种订单状态,而这种状态夹在现有状态中间,需要大改业务代码和判断逻辑。
为了解决这个问题,我们需要引入两个核心工具:领域驱动设计 (DDD) 和 状态机 (State Machine)。
DDD 与状态机
在优化代码前,先明白几个术语:
| 名词 | 术语 | 通俗解释 |
|---|---|---|
| 聚合根 | Aggregate Root | 就是那个实体类(这里是 Order)。在 DDD 里,主角要有“脑子”,逻辑要写在它里面,而不是写在 Service 里。 |
| 状态 | State | 订单现在的样子,比如“待支付”。 |
| 事件 | Event | 触发变化的动作,比如“用户支付”。 |
| 流转 | Transition | 从 A 状态变成 B 状态的过程。 |
| 卫语句 | Guard | 门卫。在变状态前检查一下合不合法(比如:没付钱不能发货)。 |
DDD 聚合根
在 DDD 中,聚合根 (Aggregate Root) 是业务逻辑的最小边界。它必须保证其内部数据的完整性和一致性。 订单 (Order) 就是一个典型的聚合根。在 DDD 看来,状态的变更不应该是 Service 层手动 setStatus,而应该是聚合根对外部事件的响应。
状态机
状态机(State Machine) 是一个经典概念,它由三个核心要素组成:
- State:实体所处的状况(如:待支付、已支付、已发货)。
- Event:触发状态流转的动作(如:支付、发货、收货)。
- Transition:从一个状态变为另一个状态的过程,通常伴随着 Guard 和Action。
DDD 与状态机的结合点
在 DDD 中,聚合根 负责维护数据的一致性。状态是聚合根最核心的属性之一。
将状态机引入 DDD,主要遵循以下原则:
- 状态即语言:状态枚举(Enum)和触发事件(Event)应该是领域专家和开发者共同认可的通用语言。
- 显式流转:禁止直接
setStatus。状态的改变必须通过触发“事件”来完成。 - 逻辑内聚:状态机配置(谁能流转到谁)应该属于领域层,而不是应用层。
实战架构设计
一个简单的电商订单生命周期:
stateDiagram-v2
[*] --> CREATED: 下单
CREATED --> PAID: 支付
CREATED --> CANCELED: 取消
PAID --> DILIVERED: 发货
PAID --> REFUNDING: 申请退款
DILIVERED --> COMPLETED: 签收
DILIVERED --> REFUNDING: 拒收/退货
REFUNDING --> REFUNDED: 退款成功
REFUNDING --> PAID: 退款失败/撤销
COMPLETED --> [*]
CANCELED --> [*]
REFUNDED --> [*]
我们要实现的流程:
sequenceDiagram
participant User as 用户(Controller)
participant Service as OrderService
participant Domain as Order(聚合根)
participant FSM as OrderStateMachine(规则表)
participant DB as 数据库
User->>Service: 请求支付 (pay)
Service->>DB: 查订单 (status=CREATED)
DB-->>Service: 返回对象
Service->>Domain: order.sendEvent(PAY)
Note over Domain, FSM: 重点业务
Domain->>FSM: 问: CREATED + PAY = ?
FSM-->>Domain: 答: 允许,新状态是 PAID
Domain->>Domain: 更新自身 status = PAID
Service->>DB: 保存订单
我们的目标是:把逻辑从 Service 挪进 Order 类里,并用一张“配置表”来管理状态变化。
第一步:定义枚举
不要用字符串 “PAID”,要用枚举。
1 | // 状态枚举 |
第二步:设计状态机
我们要告诉程序:在什么状态下,发生什么事件,变成什么新状态。
现有的状态机框架有 Spring Statemachine 和 阿里的 Cola StateMachine 等。
但是,我们现在不用复杂的框架,写一个简单的静态映射表。
1 | public class OrderStateMachine { |
第三步:改造 Order 类
现在的 Order 不再只是 getters/setters 了,它有了行为。
1 | // 一个 DDD 风格的聚合根 |
第四步:新版的 Service(变得极简)
Service 不再负责复杂的判断,它只负责从数据库查到聚合根,然后触发事件。
1 |
|
与原来对比
| 特性 | 旧写法 | DDD + 状态机 |
|---|---|---|
| 代码位置 | 逻辑散落在 Service 的各个方法里 | 逻辑内聚在 Order 类和 StateMachine 配置里 |
| 可读性 | 满屏的 if (status == 1) |
清晰的 Map 配置表,一眼看出业务流程 |
| 扩展性 | 加个新状态要改好几个 Service 方法 | 只需要在配置表里加一行代码 |
| 安全性 | 任意代码都能 setStatus,容易改错 |
禁止 setStatus,只能通过合法事件驱动 |
| 复用性 | 别的 Service 要用还得重写判断逻辑 | 直接复用 Order 类的方法 |
这种模式的挑战
在分布式环境下,状态机面临以下挑战:
状态竞争 (并发)
当两个线程同时尝试更新同一个订单的状态时(如用户点击取消的同时,支付回调到达)的时候:
在数据库上加乐观锁,增加 version 字段。
1 | UPDATE orders SET status = 'PAID', version = version + 1 WHERE id = 1 AND version = 5; |
幂等性
如果支付回调由于网络原因重试(支付回调来了两次),状态机已经处于 PAID 了,再次收到 PAY_SUCCESS 的时候:
状态机引擎应具备“自动过滤”功能。如果当前状态已是目标状态,直接返回成功,不触发 Action。
1 | public void handlePaymentCallback(Long orderId) { |
副作用与持久化
状态转换成功了,但发送消息队列失败的时候:
1 | // 错误示范 |
解决方案:本地消息表:
把“发消息”这个动作,变成“往数据库插一条记录”。这样数据库的状态更新和消息记录就在同一个事务里,失败就都回滚。
建表:local_event_table (id, event_content, status)。
Service 层:
1 |
|
异步线程:
写一个定时任务(或者监听器),专门去查 local_event_table 里 PENDING 的消息,发送给 MQ,发送成功后再把表里的状态改为 SENT。
可靠的状态变更
sequenceDiagram
participant User
participant Service
participant DB as 数据库(Order表 & Event表)
participant Worker as 异步补偿任务
participant MQ
User->>Service: 触发动作
rect rgb(240, 240, 240)
Note right of Service: 开启数据库事务
Service->>DB: UPDATE order SET status='PAID'...
Service->>DB: INSERT INTO local_event...
Service-->>User: 返回成功
Note right of Service: 提交事务
end
par 异步处理
Worker->>DB: 轮询 status='PENDING' 的消息
Worker->>MQ: 发送消息
alt 发送成功
Worker->>DB: UPDATE local_event SET status='SENT'
else 发送失败
Worker->>DB: 保持 PENDING,下次重试
end
end
复杂条件判断
不是所有 CREATED 都能变 PAID。
业务规则:只有金额 > 0 且 库存充足 才能支付。如果把这些逻辑写死在 if-else 里,Service 又乱了。
使用策略接口注入。
在状态机定义里,加入一个 Condition 接口。
1 | // 1. 定义条件接口 |
最后
- 不要过度设计。如果你的状态只有 2 个(开/关),或者订单只有几个状态,用简单的
if-else也没问题。当状态很多且流转复杂时,状态机才是神器。 - 试着把业务逻辑写进实体类里,而不是全部堆在 Service 里,这是写 DDD 的第一步。
- 持久化。数据库里存的还是简单的字符串或数字(比如
status="PAID"),状态机只是内存里的逻辑校验器,不需要把状态机存到数据库里。
通过这种方式,你的代码不再是面条,冗长难以维护和扩展,而是一台精密的仪器,每个部件各司其职。这就是架构设计的精妙之处。