我们后台订单模块里有这么一段代码,两百多行,处理各种订单状态的流转。它诞生的时间是 2019 年,那时候 JDK 8 是最新的 LTS,switch 只支持数字和字符串,别的手段有限。
这段代码,每次改状态的业务逻辑都是一场噩梦。哪个状态能转到哪个状态,全部藏在一层层的 if (order.getStatus() == 1) { ... } 里,没有一张图能画清楚。谁加逻辑,谁就怕踩错分支。
去年底项目升级到 JDK 21,我们终于把这段代码彻底重写了一遍。中间的过程有顺利的部分,也有好几个坑。下面把这些写下来。
先看看原来的代码什么样
订单状态原来是用一个枚举 OrderStatus 表示的:CREATED、PAID、SHIPPED、DELIVERED、COMPLETED、CANCELLED、REFUNDED。加上 Order 实体,以及一个 OrderStateMachine 用来处理流转。
流转的入口方法大概长这样:
public ActionResult transit(Order order, OrderAction action) {
if (action == OrderAction.PAY) {
if (order.getStatus() == OrderStatus.CREATED) {
order.setStatus(OrderStatus.PAID);
order.setPaidAt(now());
notifyPaymentSuccess(order);
return ActionResult.ok();
}
return ActionResult.fail("订单状态不允许支付");
} else if (action == OrderAction.SHIP) {
if (order.getStatus() == OrderStatus.PAID) {
order.setStatus(OrderStatus.SHIPPED);
order.setShippedAt(now());
generateTrackNo(order);
return ActionResult.ok();
}
return ActionResult.fail("订单状态不允许发货");
} else if (action == OrderAction.DELIVER) {
if (order.getStatus() == OrderStatus.SHIPPED) {
order.setStatus(OrderStatus.DELIVERED);
order.setDeliveredAt(now());
return ActionResult.ok();
}
return ActionResult.fail("订单状态不允许确认收货");
} else if (action == OrderAction.CONFIRM) {
if (order.getStatus() == OrderStatus.DELIVERED) {
order.setStatus(OrderStatus.COMPLETED);
order.setCompletedAt(now());
refundDeposit(order);
return ActionResult.ok();
}
return ActionResult.fail("订单状态不允许完成");
} else if (action == OrderAction.CANCEL) {
if (order.getStatus() == OrderStatus.CREATED
|| order.getStatus() == OrderStatus.PAID) {
order.setStatus(OrderStatus.CANCELLED);
order.setCancelledAt(now());
returnStock(order);
refundIfPaid(order);
return ActionResult.ok();
}
return ActionResult.fail("订单状态不允许取消");
} else if (action == OrderAction.REFUND) {
if (order.getStatus() == OrderStatus.COMPLETED
|| order.getStatus() == OrderStatus.DELIVERED) {
order.setStatus(OrderStatus.REFUNDED);
order.setRefundedAt(now());
returnStock(order);
refundMoney(order);
return ActionResult.ok();
}
return ActionResult.fail("订单状态不允许退款");
}
return ActionResult.fail("未知操作");
}
这段代码的问题:一是”允许的操作”和”状态”分散在各处,不知道总共有多少条合法流转;二是新增一个状态的时候,必须把整个方法看一遍才知道要改几个地方;三是极容易漏分支——曾经有过 DELIVERED 状态漏了 REFUND,客服那边申请退款被系统拒绝,客诉。
为什么选择 sealed + record + switch 模式匹配
JDK 21 里 Pattern Matching for switch(JEP 441)正式转正,加上 JDK 17 就有的 sealed class 和 record。这套组合看着就是为状态机量身定做的。
我当时的想法很简单:把”状态”和”携带的数据”捆绑成一个 sealed 家族,让编译器帮我管住穷举性。这样新增一个状态,编译期就能直接报错,不会再有漏分支的可能。
但真正写起来,”捆绑”这件事没那么简单。它牵扯到 Record 的可变性问题、序列化方案、持久化字段怎么对齐。这些我在动手之前没想清楚,所以走得比较曲折。
第一步:定义 sealed 家族
最初的设计长这样:
public sealed interface OrderState
permits Created, Paid, Shipped, Delivered,
Completed, Cancelled, Refunded {
Long orderId();
Instant enteredAt();
}
public record Created(Long orderId, Instant enteredAt, Instant expiredAt)
implements OrderState {}
public record Paid(Long orderId, Instant enteredAt, String paymentNo, BigDecimal amount)
implements OrderState {}
public record Shipped(Long orderId, Instant enteredAt, String trackNo, String carrier)
implements OrderState {}
public record Delivered(Long orderId, Instant enteredAt)
implements OrderState {}
public record Completed(Long orderId, Instant enteredAt)
implements OrderState {}
public record Cancelled(Long orderId, Instant enteredAt, String reason)
implements OrderState {}
public record Refunded(Long orderId, Instant enteredAt, String refundNo, BigDecimal amount)
implements OrderState {}
写到这里的时候,我停顿了一下。因为我在想——Created 记录里为什么要带 expiredAt?这不是订单实体的字段吗?而且”状态”作为一个值对象,它应该是不可变的,但订单的实际状态每时每刻在变。
我后来意识到自己走偏了。**sealed 家族定义的是”状态迁移的动作”,不是”状态的当前值”。** 这是两个完全不同的概念。
重新梳理之后,我改成了围绕”操作”来设计。因为状态机真正要做的事情不是”描述状态”,而是”处理操作”。每一次操作都是一次状态迁移,迁移动作不同,需要的数据也不同。
重新设计:围绕 Action 定义 sealed 家族
改成这样:
public sealed interface OrderAction
permits Pay, Ship, Deliver, Confirm, Cancel, Refund {}
public record Pay(String paymentNo, BigDecimal amount, Instant paidAt)
implements OrderAction {}
public record Ship(String trackNo, String carrier, Instant shippedAt)
implements OrderAction {}
public record Deliver(Instant deliveredAt)
implements OrderAction {}
public record Confirm(Instant confirmedAt)
implements OrderAction {}
public record Cancel(String reason, Instant cancelledAt)
implements OrderAction {}
public record Refund(String refundNo, BigDecimal amount, Instant refundedAt)
implements OrderAction {}
然后状态机就变成了一个方法,接收”当前状态”和”操作”,返回”下一个状态”或者错误:
public Result<OrderStatus, String> transit(OrderStatus current, OrderAction action) {
return switch (current) {
case CREATED -> switch (action) {
case Pay p -> Result.ok(OrderStatus.PAID);
case Cancel c -> Result.ok(OrderStatus.CANCELLED);
default -> Result.err("当前状态不支持该操作");
};
case PAID -> switch (action) {
case Ship s -> Result.ok(OrderStatus.SHIPPED);
case Cancel c -> Result.ok(OrderStatus.CANCELLED);
case Refund r -> Result.ok(OrderStatus.REFUNDED);
default -> Result.err("当前状态不支持该操作");
};
case SHIPPED -> switch (action) {
case Deliver d -> Result.ok(OrderStatus.DELIVERED);
case Refund r -> Result.ok(OrderStatus.REFUNDED);
default -> Result.err("当前状态不支持该操作");
};
case DELIVERED -> switch (action) {
case Confirm c -> Result.ok(OrderStatus.COMPLETED);
case Refund r -> Result.ok(OrderStatus.REFUNDED);
default -> Result.err("当前状态不支持该操作");
};
case COMPLETED -> switch (action) {
case Refund r -> Result.ok(OrderStatus.REFUNDED);
default -> Result.err("已完成订单不支持该操作");
};
case CANCELLED -> Result.err("已取消订单不能变更");
case REFUNDED -> Result.err("已退款订单不能变更");
};
}
这次设计比第一版好很多。但同时我发现了几个问题。
坑一:switch 里对 Optional 或 Result 解包时,case null 容易漏
第一个坑出现在真实代码接入的时候。我们的订单实体里 getStatus() 返回的是 OrderStatus 枚举,理论上不会为 null。但线上确实出现过一次历史数据问题,某些老订单的状态字段是空字符串,走 DAO 层转换的时候变成了 null,一路传到状态机。
JDK 21 的 switch 支持 case null 分支了(JEP 441 里的一部分),这是之前很多版本没有的:
return switch (current) {
case null -> Result.err("订单状态缺失");
case CREATED -> ...
// ...
};
如果不写 case null,而 current 是 null,会抛 NullPointerException。以前写 if (x == CONSTANT) 的时候 null 会走 else 分支,相对安全。现在 switch 加 null 检查,反而变成了一个陷阱:用 switch 之前必须确认参数不可能为 null,或者显式处理 case null。
我们的做法是两件事:一是在入参处加了 Objects.requireNonNull(current),二是状态机入口处加了 case null 兜底。两层防护看起来冗余,但线上数据问题从来不讲道理,宁可多做一层。
坑二:Record 的 equals/hashCode 跟你想的不一样
Record 会自动生成 equals 和 hashCode,基于所有字段。这听着很合理,但在订单场景下有个隐藏问题。
我们有一处代码把 Pay 类型的操作放进了 HashSet 去重,目的是防止同一笔支付因为网络重试被处理两次。我当时的假设是:”两个 Pay 对象,只要 paymentNo 相同就应该被去重。”
但 Record 的 equals 是全部字段比较,paidAt 不一样,amount 一样,paymentNo 一样,两个对象就不 equal。重试的请求带着略微不同的时间戳,每次都没被去重,重复支付了两次。
这个问题后来通过重写 equals 解决:
public record Pay(String paymentNo, BigDecimal amount, Instant paidAt)
implements OrderAction {
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Pay other)) return false;
return Objects.equals(paymentNo, other.paymentNo);
}
@Override
public int hashCode() {
return Objects.hash(paymentNo);
}
}
这段代码我犹豫了一会儿才写。因为重写 Record 的 equals 是反直觉的操作——你用 Record 就是因为它自带值语义,结果你又要自己覆盖。写完之后感觉有点”用 Record 却不信任 Record”的味道。
最后的结论是:**去重不该用 Record 的 equals 来做。** 后来我们改成用 paymentNo 单独维护一个 Set,操作对象还是让 Record 保持天然语义。这样职责更清晰。
坑三:Jackson 反序列化 Record 的空构造函数问题
我们有一个 HTTP 接口接收操作参数,请求体是 JSON,需要反序列化成对应的 OrderAction 子类型。一开始写的是:
@PostMapping("/order/{id}/action")
public Result action(@PathVariable Long id, @RequestBody OrderAction action) {
return service.handle(id, action);
}
结果直接 400,反序列化报错:
Cannot construct instance of `OrderAction` (no Creators, like default constructor, exist)
原因很直接——OrderAction 是个接口,Jackson 不知道要实例化哪个子类。
处理方式有两种。
方案一:在接口上标注 @JsonTypeInfo 和 @JsonSubTypes。让 Jackson 根据 JSON 里的一个类型字段来选子类:
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = Pay.class, name = "PAY"),
@JsonSubTypes.Type(value = Ship.class, name = "SHIP"),
@JsonSubTypes.Type(value = Deliver.class, name = "DELIVER"),
@JsonSubTypes.Type(value = Confirm.class, name = "CONFIRM"),
@JsonSubTypes.Type(value = Cancel.class, name = "CANCEL"),
@JsonSubTypes.Type(value = Refund.class, name = "REFUND")
})
public sealed interface OrderAction permits Pay, Ship, Deliver, Confirm, Cancel, Refund {
}
对应的 JSON 请求体变成:
{
"type": "PAY",
"paymentNo": "P202501080001",
"amount": 199.00,
"paidAt": "2025-01-08T10:00:00Z"
}
方案二:Controller 接收一个宽泛的 DTO,在服务层再做转换。不直接暴露 sealed 类型给外部,这样外部协议和内部模型解耦。
我们最终选的是方案一。原因是操作结构本身比较稳定,没有必要额外一层转换。如果将来对接更多外部系统,可能会改用方案二。
但方案一里还有个小细节:Record 的字段是 final 的,Jackson 需要知道如何构造。原生 Record 支持在 Jackson 2.12 之后已经做得很好,但前提是不能配置 FAIL_ON_NULL_FOR_PRIMITIVES 这类严格的检查。我们的项目里恰好开着这个开关,导致 Deliver 这种只有一个字段的记录,如果 deliveredAt 缺失就直接报错。
处理方式是在时间字段上加 @JsonFormat 或者给 Deliver 加一个空的静态工厂。最后选了更简单的方式——把时间参数从必传改成 Optional:
public record Deliver(Instant deliveredAt) implements OrderAction {
public Deliver {
if (deliveredAt == null) deliveredAt = Instant.now();
}
}
Record 的紧凑构造器写得好看,但要注意——它就是在这个位置注入默认值的最简洁方式。
坑四:sealed 子类必须”紧凑声明”
这个坑编译的时候就会报错,但报错信息让人摸不着头脑。
sealed interface 的子类必须在同一个模块里,如果接口在 A 模块,子类在 B 模块,除非 B 模块有 requires A 而且 A 模块打开了对应的包,否则会编译报错。当时我们的项目是个多模块的,OrderAction 放在 order-api 里,子类散落在 order-service 和 order-admin 里,编译直接失败。
处理方式是在 module-info.java 里显式 exports 对应的包,或者干脆把整个 sealed 家族都放在同一个包里。
我们选的是后者——把七个子类全部塞进 order-api/domain/action 这个包下,一个文件一个 Record,但都在同包。这样模块化异常就没那么烦人,而且读代码的时候这个包里的东西高度相关,找东西反而更快了。
顺便说一句,如果你不用 JPMS,用的是普通的 Maven 多模块(没有 module-info.java),sealed 的子类约束会按类路径来,基本上就没什么限制了。我们当时是配置了 JPMS 的,所以踩到了这个坑。如果你的项目不用模块化,可以忽略这段。
坑五:switch 模式匹配不是无限强大
用之前我对 “switch 模式匹配” 有误解,以为它可以匹配任意复杂的条件。实际上它的能力是有明确边界:
它只能匹配类型、常量、以及类型+卫语句(when)。不能做”匹配所有金额大于 100 的 Pay”这种基于字段值的模式。要做的话得写成:
case Pay p when p.amount().compareTo(new BigDecimal("100")) > 0 -> ...
case Pay p -> ...
when 这个卫语句是 JDK 21 正式版里新增的,用来做额外的条件判断。这个设计非常好,但它也有个规则:**一旦用了 when,两个分支之间不能有歧义,顺序必须注意。** 上面这段代码里,一行是”匹配大于 100 的”,一行是”匹配所有 Pay”,第二行的 when 分支必须放在前面,否则永远不会命中。
编译器不会帮你检查这种情况,你写反了它就静默地按第一个匹配走。当时我们有人把顺序写反了,导致一笔 200 元的订单进入了错误的处理逻辑,是 QA 回归的时候发现的。
坑六:过度设计,谁也没告诉我这才是陷阱
写到这里,我想说最深的那个坑,也是最不容易被 AI 或教程告诉你的坑——**用了模式匹配之后,你很容易把整个领域模型都往 sealed 家族上靠**。
我在这个重构里也走过一段。一开始我把”状态”也设计成了 sealed 家族,每个状态对应一个 Record,还把”迁移的动作”也设计成了 sealed 家族。两层都套上 sealed,一共 14 个类。然后为了把它持久化到数据库,又得写两套映射——Record 转枚举,枚举转 Record。
后来想明白了,这事根本不需要这么复杂。**状态是枚举就够用了。sealed 家族更适合用来表达”带数据的多种操作”,因为不同的操作需要传递不同的数据。而”状态”本身没有数据,它就是几个常量,枚举才是它最好的表达方式。**
最终方案就是上面那个缩小版的:状态还是枚举,操作才是 sealed 家族。整个重构落地之后,新增代码行数其实比原来少了三分之一(原来的代码 200 多行,现在的 switch 结构加七个 Record 大概 150 行),可读性提高了,但也没有”脱胎换骨”的变化。
这次经历告诉我:**模式匹配是用来”解决 if-else + instanceof 脏代码”的,不是用来重写领域模型的。** 如果你正在用 sealed 家族去表达一个本来就很自然地用枚举、Map、或者策略模式能解决的东西,那大概率是过度设计了。
收益和代价
重构上线跑了三个月,简单说一下收益和代价。
收益方面,新增一个状态变更的操作时,只需要做三件事:在 OrderAction 的 permits 列表里加上新的 Record、在 Record 类里实现接口、在各处 switch 里补上 case。前两件编译器都会提醒你,第三件会报编译错误——因为 switch 是穷举的,只要你漏了任何一个 case 分支,编译器直接告诉你缺失哪个类型。这比之前”漏了运行时才发现”强太多。
代码可读性也上来了。现在任何一个人看这个状态机,两张表:状态枚举 × 操作类型,switch 里一行一个 case,合法迁移一目了然。产品经理拿着这个代码都能看懂。
代价方面,主要在两处。一是反序列化配置比之前复杂,@JsonTypeInfo 这些注解需要团队里每个人都理解。二是有的老代码还在用原来的 OrderStatus 假设,改造之后有些边界场景(比如 status=null)需要单独处理,跟原来的代码产生了一些重复的逻辑。
还有一点,switch 表达式返回 Result<OrderStatus, String> 这种包装类,业务层每次都要 .orElseThrow() 或者手动判空,比原来直接返回枚举多一点心智负担。这个我们接受了,因为 Result 携带的错误信息比 “return null” 友好很多。
什么场景适合用模式匹配重构,什么场景不该
总结几条经验。
适合的场景:多种操作、每种操作携带不同参数、需要根据当前状态做不同处理、希望编译期帮忙检查穷举性。典型就是订单状态机、支付流程、审批流程、物流状态跟踪这类东西。
不适合的场景:状态本身没有数据(用枚举)、操作参数非常固定(用策略模式+接口)、状态机一直在频繁变动(sealed 家族的 permits 列表每次都要改,太烦)、遗留系统不能升级 JDK(直接排除)。
另外还有一类场景,用模式匹配解决看起来很美,但实际上引入了新的复杂度:就是”需要在运行时决定处理逻辑”的场景。比如我们的运营后台允许运营人员配置”当订单金额大于 500 且用户等级大于 3 时,自动打标签”。这种规则是数据驱动的,用 switch 模式匹配写死了就失去灵活性,这种情况应该用规则引擎或者 Drools,而不是靠 switch。
最后
这套东西其实 JDK 21 一出来就该做了,但项目上有各种优先级,一直往后拖。真正动手之后,两天时间改完,一周测试,第十天上线。后续处理过三个小 bug:一个是没有处理 null 状态、一个是反序列化漏了一个字段、一个是 when 分支顺序写反。都不是大问题。
回头看,我觉得最值得分享的不是”模式匹配怎么用”,因为教程太多。真正值得说的是那些”事情不顺”的地方——Record 的 equals 不等于业务语义、sealed 家族受模块化限制、when 写反了编译器不管。这些才是真正用起来才知道的东西。
如果你也在做类似的重构,我的建议是:先花一天时间想清楚哪些东西进 sealed 家族,哪些留在枚举/普通类里。这一步做错了,后面所有工作都要返工。我们当时就是在这上面犹豫了一下午,才把”状态”和”操作”分开,否则整个重构会非常痛苦。

