两台同型号相机的描述完全相同,仍不能合并成一台;同一台相机更换标签、送检、退役以后,设备身份却没有改变。金额恰好相反:两个人民币十元的值通常可以互换,没有必要追踪“最初创建的那一个十元对象”。相等规则需要先服从业务含义,再选择语言机制。

时间、数量与身份 已讨论半开租期与金额表示。本篇在未修改的 Java 租赁基线上复用 `EquipmentId` 和 `Period`,新增设备实体、设备描述和金额值对象,测试生命周期、相等及别名;这些规则仍未接入预留用例,实验会直接暴露这个缺口。

身份连续性决定 Entity 的含义

Evans 的《DDD Reference》用身份和生命周期连续性描述 Entity:属性可能变化,不同表示仍可能指向同一对象。Value Object 则表达所关注的属性及相关行为,应作为不可变值使用。2015年模式摘要,印刷页11—12

本实验的设备实体 Equipment 以设备编号相等。E1 与 E2 即使型号、标签和状态全部相同,仍然不相等;从 E1 的快照重新构造一个对象,与原对象不是同一个 Java 引用,却仍代表同一台设备。这里的编号继承现有核心类型,没有额外去空格或大小写归一化。

classDiagram
    Equipment --> EquipmentId : 稳定身份
    Equipment --> EquipmentDescription : 当前描述值
    Equipment --> Snapshot : 导出状态快照
    Snapshot --> EquipmentId
    Snapshot --> EquipmentDescription
    EquipmentDescription : model
    EquipmentDescription : immutable tags
    Money : amount
    Money : currency
    Period : start Instant
    Period : end Instant

图中 EquipmentId、Period 来自现有 RentalDesk,其余为本章新增。设备编号虽有“身份”二字,它自身仍是一个按内容比较的小值对象;设备实体才承担那条跨状态变化的业务连续性。不能因为一个类型包装了字符串主键,就把它当成完整 Entity。

equals 与 hashCode 都只使用不可变编号。把设备加入 HashSet 后再审批、改描述,哈希值保持不变,集合仍能找到它。若相等规则使用可变状态,同一个设备变更后可能落入另一个哈希位置,集合行为就与业务身份脱节。

相同身份不代表相同状态

测试先保存 E1 的快照,再恢复另一份 E1 表示;原对象通过检验后,两个设备实体仍相等,但快照不再相等。前者回答“是不是同一台”,后者回答“记录内容是否一致”。需要比较新旧状态时,不能直接复用实体相等判断。

这个区别也限制了 restore 的用途。它从可信快照恢复状态,不重放新建审批流程;它没有判断快照是否过期、是否来自正确数据库或是否被篡改。方法使内存重建可以验证,但尚未实现 Repository,更没有并发版本检查。

编号唯一性同样没有靠构造器解决。内存中可以创建两个编号都为 E1 的对象,这是测试跨表示身份的必要条件;持久化时是否允许同一编号对应两台实物,需要仓储约束和入库规则保障。UUID、数据库主键或字符串类型都不能自行完成业务识别。

生命周期由操作保持

新设备先处于 INSPECTION_REQUIRED,通过租赁方检验后进入 ACTIVE;使用中的设备可以重新要求检验;两个状态都可以退役,退役后不允许重新激活或修改描述。这些是本章合成规则,不是所有设备管理系统的通用政策。

stateDiagram-v2
    [*] --> INSPECTION_REQUIRED : 登记设备
    INSPECTION_REQUIRED --> ACTIVE : approveInspection
    ACTIVE --> INSPECTION_REQUIRED : requireInspection
    INSPECTION_REQUIRED --> RETIRED : retire
    ACTIVE --> RETIRED : retire
    RETIRED --> [*]

实体没有公开状态赋值器,只提供有前置条件的操作。重复审批、退役后要求检验、退役后改描述等都会被拒绝,测试同时确认最终状态仍为 RETIRED。重复退役也被拒绝;若将来应用需要幂等退役请求,应在请求语义中明确处理,不能偷偷改变这里的操作契约。

维修系统的完成状态不能直接写成 ACTIVE。防腐层 的 RELEASED 只说明维修侧限制解除,租赁方是否已经验收仍是自己的判断。两套模型之间需要翻译和用例协调,而不是按枚举序号相互赋值。

设备可以在原对象上改变状态,因此两个引用指向同一设备时,其中一个完成审批,另一个也会观察到 ACTIVE。这种别名效果符合本例的实体实现。不可变的旧快照则继续保存原状态,测试把两者分开,避免误以为导出快照就是导出活动对象引用。

Entity 也可以采用不可变实现,每次操作返回带相同编号的新表示;身份连续性并不要求 Java 字段必须可变。本例选择原地改变设备,目的是把活动对象别名和快照差异直接展示出来。两种实现都仍需处理多个表示之间的同步,不能因为改用不可变对象,就忽略仓储中同一设备可能被两次并发更新的问题。

record 也可能包含可变别名

EquipmentDescription 包含型号字符串和标签列表,是设备当前描述的值。标签按有序列表比较,顺序变化在本模型中意味着值变化;若业务只关心集合,需要明确改成集合语义,不能让实现偶然决定。

实验先构造一个只保存 List 引用的反例 record,再修改外部原列表,record 内部也随之变化。record 的字段不能重新赋值,不代表字段引用的对象不会改变。用它承载值对象仍需要检查内部成员。

正式描述对象在构造时调用 List.copyOf。外部列表再增加标签,不影响已经创建的描述;从访问器取得列表后尝试添加,也会抛出异常。这里能形成稳定的值,是因为容器不允许修改,元素又是不可变的 String。Java 官方文档并不保证它会递归复制任意可变元素。Java 21 List 文档

描述变更通过创建新描述并交给实体完成,原描述仍可供旧快照引用。它不需要新的业务身份,也不记录谁审批了规格修订。如果某个上下文需要管理目录修订的审批与发布,那份规格可能需要独立身份;当前轻量描述值不能替代该生命周期。

金额的相等必须包含币种与精度政策

Java 的 BigDecimal.equals 同时比较数值与 scale,因此 10.0 与 10.00 并不相等,而数值比较可认为它们等价。直接把任意 BigDecimal 放进 record,可能得到不符合本例金额语义的相等结果。Java 21 BigDecimal 文档

本章 Money 仅支持 CNY 和 USD,构造时精确规范到两位小数。十元的两种写法因此得到相等对象及相同哈希;十美元则不等于十人民币。相加要求币种相同并返回新值,原操作数不变,没有隐含换汇。

精度策略是 RoundingMode.UNNECESSARY:0.001 会被拒绝,而不是悄悄四舍五入。模型允许负金额,用于表达有符号差额;负值不代表退款已经执行。支持范围以外的币种被拒绝,不能据此推导所有货币都使用两位小数。

金额的构造限制与业务收费限制也要分开。零金额或负差额作为值可以有意义,但“本次付款必须大于零”属于具体用例。把所有场景的限制集中塞进 Money,会导致正常的调整计算也无法表达。

四色与战术类型没有自动对应表

四色建模 关注概念在业务结构中的角色,Entity 与 Value Object 关注身份及相等语义。设备通常有独立生命周期,但描述类也可能需要管理修订身份;同一时间区间可以作为租期值,也可能参与一份有身份的合同。

本例的 Period 沿用核心中基于 Instant 的非空半开区间,相同起止值相等,相邻区间不重叠。它并不因为描述一段时间就自动成为 Entity。是否需要追踪“这一份租期约定”的修订,应由合同或其他具名记录承载,而不是随意给每个区间加主键。

可运行结果与累计契约

下载 Java实验与契约,设置好 JDK 21 的 JAVA_HOME 后,在解压根目录执行:

1
bash examples/software-modeling/labs/24/run.sh

脚本使用 --release 21 -Xlint:all -Werror 编译原始核心、原始基线检查和本章代码。本次 Corretto 21.0.11 上,原有48项检查与新增36项检查通过,退出0;输出及版本保存在 evidence/24/run.txt,编译产物只存在于临时目录。

其中一项特意把退役设备的编号直接交给旧 RentalDesk,预留仍然成功。这个结果标出真实边界:设备实体已实现,但核心不知道其生命周期。models/24/contracts.md 为后续应用保留身份、状态转换、金额和快照契约,并要求在预留入口协调生命周期、维修限制与区间冲突。

这一步还不能随意改变原有请求重放语义。设备在首次成功预留后退役,旧请求再次到达时,应该先返回历史回执还是先检查新资格,需要应用用例明确选择。当前实验保持基线不动,把缺口和待接入责任公开,避免把对象模型完成误报成整条业务链已完成。