订单状态一旦扩展到取消、支付、发货、退款和售后,散落在服务层的 if-else 很快会变成难以修改的业务迷宫。本文以订单状态流转为例,分析条件分支失控的根因,并用状态模式重构一套职责清晰、便于测试和扩展的 Java 代码。
问题背景:状态不是一个字段,而是一组约束
很多业务对象都有状态:订单有待支付、已支付、已发货,工单有待处理、处理中、已完成,账户有正常、冻结、注销。最初的实现往往很简单,在一个 Service 里写几个判断:
public void changeStatus(Long orderId, String action) {
Order order = orderRepository.findById(orderId);
if ("pay".equals(action)) {
if (!"CREATED".equals(order.getStatus())) {
throw new IllegalStateException("只有待支付订单可以支付");
}
order.setStatus("PAID");
} else if ("ship".equals(action)) {
if (!"PAID".equals(order.getStatus())) {
throw new IllegalStateException("只有已支付订单可以发货");
}
order.setStatus("SHIPPED");
} else if ("cancel".equals(action)) {
if (!"CREATED".equals(order.getStatus())
&& !"PAID".equals(order.getStatus())) {
throw new IllegalStateException("当前状态不能取消");
}
order.setStatus("CANCELLED");
}
orderRepository.save(order);
}这段代码在只有三个动作时并不糟糕。真正的问题出现在需求继续增长之后:支付成功要记录时间,发货前要检查库存,取消要根据付款状态触发退款,退款又会引入新的状态。状态判断、业务动作、异常提示和副作用逐渐混在一起。
此时,代码的复杂度并不只来自分支数量,而来自状态与动作之间的组合关系。一个状态新增一条规则,可能需要修改多个方法;一个动作增加一个前置条件,也可能影响其他流程。修改成本和回归风险一起上升。
先明确核心原则:状态决定允许做什么
状态模式适合解决这样的问题:对象的行为会随着内部状态改变,并且不同状态下的行为差异较大。
在订单场景中,可以把问题拆成三层:
Order负责保存订单当前状态,以及对外提供稳定的业务操作。OrderState表示某个状态下允许执行的动作。- 具体状态类负责实现该状态下的规则,例如待支付订单允许支付和取消,但不允许发货。
这样做不是为了把一个类拆成很多类,而是为了让变化集中。状态相关的规则放在状态对象中,订单本身不需要知道每一种状态的细节。
第一步:用枚举表达持久化状态
数据库中的状态通常仍然适合使用枚举对应的字符串或数字。枚举可以避免业务代码到处出现魔法字符串:
public enum OrderStatus {
CREATED,
PAID,
SHIPPED,
COMPLETED,
CANCELLED,
REFUNDED
}这里的枚举只描述状态集合,不直接承担全部流转逻辑。状态值需要持久化,因此新增枚举值时还要同步考虑数据库兼容性、历史数据和查询条件,不能只把它当作普通常量。
第二步:定义状态行为接口
状态接口只声明订单真正需要的动作。示例中保留支付、发货、完成和取消四个动作:
public interface OrderState {
void pay(OrderContext context);
void ship(OrderContext context);
void complete(OrderContext context);
void cancel(OrderContext context);
}为了让状态对象能够修改订单并调用外部能力,可以传入一个上下文。上下文不应无限膨胀,否则状态类会变成另一个万能 Service:
public interface OrderContext {
OrderStatus status();
void changeTo(OrderStatus targetStatus);
void recordPayment();
void arrangeShipment();
void releaseInventory();
void refundPayment();
}实际项目中,OrderContext 可以由订单聚合根实现,也可以是一个更窄的领域服务接口。关键是不要把 Repository、HTTP 客户端、所有系统服务都直接暴露给每个状态类。
第三步:提供默认拒绝行为,避免重复代码
并不是每个状态都允许所有动作。可以在抽象基类中统一处理不允许的操作:
public abstract class AbstractOrderState implements OrderState {
protected IllegalStateException notAllowed(String action) {
return new IllegalStateException(
"订单当前状态不允许执行操作:" + action);
}
@Override
public void pay(OrderContext context) {
throw notAllowed("支付");
}
@Override
public void ship(OrderContext context) {
throw notAllowed("发货");
}
@Override
public void complete(OrderContext context) {
throw notAllowed("完成");
}
@Override
public void cancel(OrderContext context) {
throw notAllowed("取消");
}
}然后分别实现具体状态:
public final class CreatedState extends AbstractOrderState {
@Override
public void pay(OrderContext context) {
context.recordPayment();
context.changeTo(OrderStatus.PAID);
}
@Override
public void cancel(OrderContext context) {
context.releaseInventory();
context.changeTo(OrderStatus.CANCELLED);
}
}
public final class PaidState extends AbstractOrderState {
@Override
public void ship(OrderContext context) {
context.arrangeShipment();
context.changeTo(OrderStatus.SHIPPED);
}
@Override
public void cancel(OrderContext context) {
context.refundPayment();
context.changeTo(OrderStatus.REFUNDED);
}
}
public final class ShippedState extends AbstractOrderState {
@Override
public void complete(OrderContext context) {
context.changeTo(OrderStatus.COMPLETED);
}
}状态对象通常可以设计为无状态对象,并在工厂中复用。它们不应该保存订单编号、用户信息或本次请求的数据,否则单例复用时会产生线程安全问题。
第四步:让订单聚合根统一管理状态切换
订单对象不应该允许调用者随意 setStatus。如果任何代码都可以直接修改状态,那么状态模式只是表面上的封装,规则仍然可能被绕过。
public class Order implements OrderContext {
private final Long id;
private OrderStatus status;
private final OrderStateFactory stateFactory;
public Order(Long id, OrderStatus status, OrderStateFactory stateFactory) {
this.id = id;
this.status = status;
this.stateFactory = stateFactory;
}
public void pay() {
currentState().pay(this);
}
public void ship() {
currentState().ship(this);
}
public void complete() {
currentState().complete(this);
}
public void cancel() {
currentState().cancel(this);
}
private OrderState currentState() {
return stateFactory.get(status);
}
@Override
public OrderStatus status() {
return status;
}
@Override
public void changeTo(OrderStatus targetStatus) {
this.status = targetStatus;
}
@Override
public void recordPayment() {
// 记录支付时间、支付流水等领域动作
}
@Override
public void arrangeShipment() {
// 创建发货任务,实际系统中通常还需要事务或领域事件
}
@Override
public void releaseInventory() {
// 释放已预占库存
}
@Override
public void refundPayment() {
// 创建退款请求,不能假设第三方调用一定同步成功
}
}状态工厂负责把持久化状态转换为行为对象:
public class OrderStateFactory {
private final Map<OrderStatus, OrderState> states;
public OrderStateFactory() {
Map<OrderStatus, OrderState> map = new EnumMap<>(OrderStatus.class);
map.put(OrderStatus.CREATED, new CreatedState());
map.put(OrderStatus.PAID, new PaidState());
map.put(OrderStatus.SHIPPED, new ShippedState());
map.put(OrderStatus.COMPLETED, new AbstractOrderState() { });
map.put(OrderStatus.CANCELLED, new AbstractOrderState() { });
map.put(OrderStatus.REFUNDED, new AbstractOrderState() { });
this.states = Collections.unmodifiableMap(map);
}
public OrderState get(OrderStatus status) {
OrderState state = states.get(status);
if (state == null) {
throw new IllegalArgumentException("未配置订单状态:" + status);
}
return state;
}
}示例需要导入 java.util.Collections、java.util.EnumMap 和 java.util.Map。在真实项目里,也可以把终态实现成专门的 FinalState,让“终态不可操作”表达得更清晰。
事务边界不能被设计模式掩盖
状态模式只解决职责分配,不自动解决事务一致性。比如支付动作中,支付记录写入、订单状态更新和库存处理是否在同一个事务内,需要根据业务事实判断。
尤其要注意以下情况:
- 状态已经改成
PAID,但支付流水还没真正成功。 - 退款接口调用超时,订单是否应该立即进入
REFUNDED。 - 发货动作由消息异步执行时,订单状态与物流状态是否要拆开。
- 多个请求同时调用
cancel和pay时,是否有乐观锁或其他并发控制。
比较稳妥的做法是把“业务事实”与“外部动作结果”区分开。例如,退款请求已创建,不一定等于退款成功,可以增加 REFUNDING 状态,或者使用独立的退款单记录过程。不要为了减少枚举值,把多个真实阶段压缩成一个含义模糊的状态。
常见坑:把状态模式改成更复杂的 if-else
第一种坑是状态类里继续判断所有其他状态。比如 PaidState 中又写 if (订单类型...)、if (渠道...)、if (用户等级...)。这样只是把原来的大分支搬到了多个小文件,复杂度没有消失。
第二种坑是状态类直接依赖大量基础设施。状态类一旦同时调用库存、支付、消息、物流和数据库,就很难单元测试,也很难判断一次动作的事务边界。可以通过领域服务接口、事件发布器或应用层编排来收窄依赖。
第三种坑是让调用方直接操作状态字段。只要仍然存在公开的 setStatus,任何控制器、定时任务或脚本都可能绕过流转规则。状态修改入口应该尽量集中。
第四种坑是为了模式而模式。如果状态只有两个,并且规则稳定、变化很少,简单的枚举加一个清晰的转换表可能更合适。设计模式的价值不在类的数量,而在于它是否降低了变化带来的影响范围。
如何验证重构确实改善了代码
重构后不要只看目录是否“更面向对象”,还要验证行为是否变得可观察、可测试。至少可以覆盖以下测试:
@Test
void createdOrderCanBePaid() {
Order order = new Order(1L, OrderStatus.CREATED, new OrderStateFactory());
order.pay();
assertEquals(OrderStatus.PAID, order.status());
}
@Test
void shippedOrderCannotBeCancelled() {
Order order = new Order(1L, OrderStatus.SHIPPED, new OrderStateFactory());
assertThrows(IllegalStateException.class, order::cancel);
}还应测试副作用是否发生。例如待支付订单取消时释放库存,已支付订单取消时发起退款;不允许的动作则不应调用任何外部服务。若使用 Mock,需要验证调用次数,而不是只验证最终状态。
实践建议
在实际重构中,可以按以下顺序推进:
- 先画出当前状态和动作的转换表,找出非法路径与隐藏副作用。
- 先把字符串状态替换为枚举,减少拼写错误和散落常量。
- 保留原有接口,内部逐步把状态判断迁移到状态对象中。
- 每迁移一个动作,就补充成功、失败和非法状态测试。
- 最后再处理异步事件、事务拆分和并发控制,不要一次改变所有边界。
如果状态转换已经呈现明显的网状关系,还可以进一步使用显式转换表,把“源状态、动作、目标状态”作为数据管理。但无论采用状态类还是转换表,都应该让非法转换尽早失败,让规则能够被读懂、被测试、被审查。
总结
状态模式并不是消灭所有条件判断,而是把“不同状态下的不同行为”放回状态本身。订单聚合根负责守住入口,状态对象负责表达规则,应用层和基础设施负责事务、持久化以及外部协作。
真正有价值的重构,通常不是把一个大类拆成十个小类,而是重新划清变化边界:新增一种状态时,应该主要影响状态定义和对应实现;修改某个状态的动作规则时,不应迫使调用者理解全部订单流程。
当一段代码的分支已经开始描述一张复杂的业务流程图,与其继续增加注释和嵌套,不如先把状态、动作和副作用分别说清楚。代码一旦能够准确表达业务约束,维护它的人才不必依靠记忆来避免下一次修改出错。