三个布尔字段 paid、shipped、cancelled 可以组成八种值。若订单只能在未付款时取消,发货又必须以付款为前提,其中只有四种形状有业务含义。paid=false, shipped=true 仍然能通过 Java 编译;每个字段各自合法,组合却不合法。

本章用 Java 21 的 record 与 sealed 建立一个教学订单模型,把“属于哪个阶段”和“该阶段需要什么数据”放进类型结构,再在外部输入进入领域模型的位置检查内容。实验只处理草稿、付款、发货和付款前取消,不包含退款、部分发货或支付网关事实核验。有限模型的用途是把保证范围写清,而不是假装几种类型已经覆盖完整电商系统。

系列导读与能力自测

先数状态,再决定字段

可以从四个有效布尔组合推导阶段:三个字段均为 false 是草稿;只有 paid 为 true 是已付款;paid 与 shipped 为 true 是已发货;只有 cancelled 为 true 是已取消。其余四种组合都需要排除。若继续保留三个独立字段,每个构造入口、数据迁移和更新路径都要维护这组关系。

约束也能写进普通类的构造器,并不要求使用函数式语法。问题在于模型能否直接告诉使用者哪些字段可以同时出现。把状态改成一个 enum,虽然消除了布尔组合,若收据和物流单号仍放在共同的可空字段里,草稿附带物流号的问题还在。枚举标签与有效载荷必须一起设计。

和类型表示若干分支之一。积类型表示多个字段同时存在。例如已付款订单需要订单号与收据,它们构成一个积;所有订单可以是草稿、已付款、已发货或已取消,四种分支构成一个和。这里的“和”“积”描述可能值的组合方式,不是对象布局或内存大小。JDK 21 的模式 switch 文档说明 sealed 层次如何让编译器检查分支覆盖;领域约束则由下面的教学模型给出。

在一个有限测试域里,订单号有两种,收据有三种,那么 Paid(id, receipt) 有六种值。若草稿只有订单号,草稿与已付款合起来有二加六种值。这里已经排除了 null,并假定每个标识符都通过构造检查;把 Java 所有运行时引用值直接当成数学集合会漏掉这些前提。

用分支携带各自需要的数据

实验中的主体结构如下,完整构造检查保存在可运行源码中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
sealed interface Order permits Draft, Paid, Shipped, Cancelled {}
record Draft(String id) implements Order {
Draft { requireId(id); }
}
record Paid(String id, Receipt receipt) implements Order {
Paid { requireId(id); Objects.requireNonNull(receipt); }
}
record Shipped(String id, Receipt receipt, Tracking tracking) implements Order {
Shipped {
requireId(id);
Objects.requireNonNull(receipt);
Objects.requireNonNull(tracking);
}
}

Shipped 同时保存收据与物流单号,因此成功构造后不必再问“已经发货,收据是否恰好为空”。Draft 没有物流字段,程序不能通过它的访问器取得物流信息。某个分支没有某个字段,是比“字段存在但使用前请判断状态”更直接的接口限制。

record 自动生成组件访问器与值相关方法,具体生成规则见 Java 21 Record Classes。它不会验证收据是否真实,也不会递归冻结任意对象。当前组件采用 String 和经过校验的记录值,没有引入可变列表。若以后加入订单明细,应另行检查列表快照与元素的不可变性,不能从最外层 record 推导整个对象图不可变。

共同的 Order 接口也没有暴露 receipt 方法。调用者必须先判断分支,才能读取阶段特有的数据。如果把所有访问器搬回父接口,再让不适用的分支返回 null,模型又恢复了原来的组合问题。公共方法适合表达所有分支都具有的能力,分支专属事实应留在分支内。

Cancelled 保存取消理由,且不保存付款收据,反映的是本实验“仅付款前取消”的政策。若业务后来允许退款后关闭,应新增能够保存退款事实的模型或重新定义关闭阶段。不能简单把 Paid 变成 Cancelled 并丢弃收据,然后仍声称审计数据完整。

受控构造补上字符串内部的约束

String 的取值范围比收据号更宽。空串、空白串和 null 都需要单独处理。最小值类型把这个检查集中到构造时:

1
2
3
4
5
6
7
record Receipt(String value) {
Receipt {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("receipt");
}
}
}

这份类型保证通过普通构造器建立的 Receipt 包含非空白文本。它不保证文本满足某家支付机构的格式,更不保证已经到账。把约束分成这两层很有必要:本地结构验证可以同步完成,外部事实通常需要一个端口及其执行结果。类型名可以表达已经获得某种事实,但创建它的可信代码仍要负责核验。

构造检查也不等于构造权限控制。当前例子允许同一程序直接创建 Paid("o1", new Receipt("r1")),因此只能声明这个值的形状完整。若业务要求付款状态只能由特定转换产生,应进一步收窄构造器可见性,或将具体分支隐藏在模块内。即便如此,数据库更新竞争、重复请求与权限校验依然需要独立协议。

Java 的引用变量仍然可以为 null。Receipt 拒绝 null 内部值,Paid 还必须拒绝 null Receipt 引用;少了后一项,new Paid("o1", null) 仍会破坏“已付款必有收据”的约束。每一层包装都应说明自己排除的是哪一层空值,不能把相邻层的保证自动继承过来。

外部正常错误不一定适合用构造异常表示。本章保留异常以便集中观察被拒绝的构造;第 13 篇会把解析失败与业务拒绝做成显式结果。在已有服务里也可以保留私有严格构造器,让公开工厂返回 Result。两种方式的共同目标是只有校验成功的路径才产生有效领域值。

外部数据要经过翻译

网络或文件输入可能仍是一个平铺对象。教学入口使用 Raw(id, state, receipt, tracking),因为它需要能够容纳错误输入,才能报告错误。Raw 的字段组合宽于 Order 是合理的:一个表示待验证数据,一个表示已经通过本地规则的数据。

decode 根据 state 选择分支。草稿拒绝任何收据和物流号;已付款必须提供 Receipt,并拒绝额外物流号;已发货必须同时提供 Receipt 和 Tracking。未知状态直接拒绝。这里没有把多余字段悄悄丢掉,因为调用者可能误以为这些数据已经被接受。

1
2
3
4
5
6
case "paid" -> {
if (raw.tracking() != null) {
throw new IllegalArgumentException("extra tracking");
}
yield new Paid(raw.id(), new Receipt(raw.receipt()));
}

边界政策应精确到空字符串。当前草稿对空字符串收据也视为额外字段,因为“未提供”约定为 null;已付款的收据空字符串则由 Receipt 拒绝。如果文件格式把空字段统一解析成空串,适配器需要先按协议归一化,不能让各个领域构造器各自猜测上游习惯。

decode 没有提供取消分支,这是有意限制的导入格式:本实验可在内部表示 Cancelled,但外部导入只接受前三种阶段。领域能够表达某种状态,并不意味着每个 API 都授权创建它。以后开放取消导入时,应给 Raw 增加理由字段或另设命令入口,并补相应断言。

这个函数也没有读取持久化数据。paid-roundtrip 断言只比较解析出的值与预期 Paid 记录相等,名称中的 roundtrip 不表示数据库写入、JSON 序列化或支付系统回查。实验观察面就是内存数据,不能把这个成功结果扩大为外部流程完成。

持久化形状不必等于领域形状

数据库表可能因历史兼容继续保存 status、receipt、tracking 几列。领域层采用分支类型,不要求立刻把所有表拆成分支表;关键是读取这些列以后,先按状态校验字段组合,再构造对应分支。否则只是给旧记录套了一层新类名,非法组合仍会进入核心计算。

写回时也需要明确每个分支输出哪些列。已支付分支不应保留旧记录里残留的物流编号,已发货分支则必须写出付款凭据与物流编号。将不存在的字段清空是映射约定,不能依靠某次更新恰好覆盖所有列。本章只实现读取转换,没有测试数据库更新或编解码往返,不能把读取成功外推为持久化正确。

新增状态时,兼容性问题通常先出现在边界。例如接收端仍只认识草稿、已支付、已发货,发送端开始产生已退款。接收端应该明确拒绝未知状态,还是将其保存为待升级处理的原始事件,需要协议层决定;不能用默认分支把它悄悄当成草稿。保留原始数据与允许它进入已验证领域模型,是两件不同的事。

分支类型还会影响接口粒度。若某函数只处理发货操作,可以直接要求 Paid,而不是接受任意 Order 后再逐次检查。这样调用者必须在此前处理其他分支,函数内部就不需要重复解释状态。但如果函数本来就是一个接收所有订单的入口,参数仍应是 Order,由入口返回明确的拒绝结果。缩窄类型要依据职责,不能只是把检查转移到一个无人维护的调用点。

运行与反例

在仓库根目录执行:

1
node examples/functional-programming/run.mjs 10

完整 Java 源码由公共运行器复制到临时目录,用 JDK 21 编译与执行。本章结果证据保存命令、源码哈希、退出码和断言输出。

正例验证八个布尔组合中只有四个满足指定规则,并验证已付款与已发货的合法解析。负例包括空白收据、null Receipt、草稿带物流号、付款状态带物流号、发货缺少收据,以及拼错的状态名。每个负例都要求实际抛出约定范围内的异常;没有抛出时,测试直接失败。它不会把日志里打印一个“错误”词当成通过。

这些断言还揭示类型与运行时检查的分工。未提供 Shipped 的必需构造参数属于编译器能发现的调用形状错误;提供空文本需要运行时拒绝;提供伪造但非空的收据号则超出当前实验。设计说明应逐项写出这种边界,而不是用“非法状态不可表示”覆盖所有错误类别。

与既有 Scala 建模文章的衔接

Scala 的 ADT 与 enum讨论了分支携带数据、命令与状态的区别,以及公开构造器的限制。本章保留那些语言说明,新增 Java 的平铺输入翻译与多层 null 检查。两篇文章的最小订单模型都不是完整领域模型,未覆盖的业务状态需要另行设计。

在调用侧,ADT 带来的收益是可读的分支处理。付款页面接收 Paid 可以直接使用 receipt;接收 Order 则必须明确哪些状态可展示收据。后续的模式匹配章节会把这种结构用于转换与折叠,并验证新增分支怎样触发遗漏检查。

手算与修改练习

手算题:设订单号只有两种、收据只有三种、物流号只有四种,忽略取消并排除 null。Draft、Paid、Shipped 各有多少值,Order 总共有多少?答案分别是二、六、二十四,总计三十二。若把三个阶段标签与两个可选标识符平铺,可能值更多;多出的值中包含不合阶段要求的组合。

修改题:为取消导入增加独立输入类型 CancelRaw(id, reason),实现 cancel(Order current, CancelRaw input)。只有订单号匹配且 current 为草稿时才允许取消;要求空白理由拒绝,合法理由保留原订单号。不要向所有 Raw 增加一个随时可空的退款对象。再写一条负断言,证明付款状态不能通过取消入口无条件丢失付款收据。完成后重跑本章命令,以新增断言的真实结果评估修改。

若需求改成允许付款后退款,再画出需要保留哪些标识符和金额。这个变化会改变数据形状及允许转换,不能只把布尔字段 cancelled 改成 true。类型设计的有效性取决于它准确表达的那组业务规则。