软件建模27:应用用例、Repository 与领域事件
设备检验通过、维修限制解除、租期合法,仍不足以说明一次预留已经完成。预留还会产生占用、历史回执和合同草案;其中一项保存失败,其他项应该怎样处理?成功结果已经保存,通知发送失败,重复请求又该返回什么?这些问题需要一个明确的应用用例来协调。
聚合与一致性边界 已证明日历内的互斥,规则、报价与创建 则生成合法草案。本篇将它们接到同一个 Java 应用中,复用设备实体和金额类型,实际检查仓储往返、请求回放及提交与发布之间的故障。用例协调已有领域行为
RentalApplication.reserve 接收完整命令和显式时钟。命令包含请求编号、设备编号、租期、费率及费率修订号。设备状态继续由第24篇的实体保持,租期规则和报价使用第26篇实现,占用仍由第25篇日历判断,应用层负责调用顺序与结果保存。
新请求必须找到设备,设备须为 ACTIVE,维修评估须为 RELEASED,租期须满足提前一小时、最长四十八小时的教学参数。费率仍按完整小时向上取整。工厂生成的对象只是合同草案;它没有证明签署或收款,也不能绕过日历的重叠检查。
flowchart TD
C[预留命令与当前时间] --> H{全局回执已存在}
H -->|同负载| R[返回历史结果]
H -->|变负载| X[拒绝编号复用]
H -->|新请求| Q[设备资格与维修评估]
Q --> W[租期规则与合同工厂]
W --> A[日历检查并记录占用结果]
A --> S[一起提交回执和合同草案]
S --> P[提交后发布事件]
图中“全局”仅指同一个仓储保存的应用状态,覆盖其中所有设备。它没有建立跨部署编号注册中心。维修端口也是教学边界:调用方提供按第23篇语言翻译后的可信枚举,本篇没有联网运行维修系统,也没有把 Python 适配器移植成 Java 服务。
RELEASED 只表示维修侧限制解除。设备退役时,即使维修端口返回它,新预留仍被拒绝。端口异常或返回空值会使本次操作失败,不会被转换成许可;维修结果的新鲜度、来源认证与持久保存还没有实现。
重放先于当前资格
一次成功预留以后,设备可能退役,时间可能越过租期,维修端口也可能暂时不可用。相同命令的重放仍然询问上次请求的结果。若先检查当前资格,同一个请求编号就可能先返回成功,随后返回退役拒绝。
因此应用先查历史回执,再检查新请求条件。实验把设备退役,把时钟推进,并换成一旦调用就抛错的维修端口;旧请求仍返回 CONFIRMED,端口没有被访问。新请求使用另一个编号,才按当前条件得到 INELIGIBLE。
完整负载比较还包括费率和修订号。相同编号换设备、改租期或换价格来源,都应拒绝,而不能静默采用新值。当前时间和维修评估属于环境输入,不纳入命令相等;否则正常重试就会因时间流逝而变成另一份负载。
业务拒绝也保存回执。一次因占用而失败的请求,在原占用取消后重放,仍返回原拒绝;使用新编号才能重新尝试。技术异常则没有成功提交回执,允许同一编号重试。这一区分让调用方可以判断是在查询历史,还是发起新的业务意图。
拒绝顺序也是应用契约。设备不存在、设备不合格、维修受限、租期不合法依次判断,因此同一请求同时违反多个条件时,调用方看到的原因稳定。占用冲突只有通过这些检查后才判断;这里没有尝试一次返回所有问题,也没有把第一条错误解释成唯一错误。
仓储需要保存完整状态
Repository 隔离领域对象的取得与保存细节。Fowler 的模式目录将它描述为领域对象与数据映射之间的集合式接口;这里采用一个受限的内存实现,接口同时承担本地提交边界,并非通用数据库仓储。Repository 模式
应用快照包含设备、日历、全局回执和合同草案。read 返回不可变快照;transact 从它恢复独立工作对象,执行用例,再导出新快照。仓储只接受本应用产生的可信完整快照,不认证外部来源,也没有对任意拼接的多张映射做完整审计。
测试先预留,再从快照重新构造仓储,检查全部内容相等。另一个测试故意带出事务内设备引用,在事务外退役该对象;再次读取仓储,设备仍处于已提交状态。工作对象与已提交快照分开,避免普通别名修改穿透保存边界。
这与保存对象引用的 Map 有实质差别。若 Map 直接持有可变设备,调用方法后即使后续保存失败,旧对象也早已变化。本实现每次恢复工作副本,失败时丢弃副本;成功后,重新取得的设备才反映已提交的退役状态。
全局回执与日历回执各有范围。前者记住所有已提交的业务结果,后者只保存真正进入占用判断的请求。未知设备的拒绝不应为了凑齐记录而伪造日历占用。确认结果才保存合同草案,草案保留当次费率,恢复时不会拿现在的价格覆盖过去的报价。
提交前后是两种失败
仓储在单实例锁内运行事务回调,最后用一次赋值替换已提交快照。它借用了集中提交变更的思路,但没有数据库写集、版本冲突检测或崩溃恢复,不能据此声称完成了通用 Unit of Work。Unit of Work 模式
实验的 BEFORE_COMMIT 故障点位于最终赋值之前。此时工作副本已经计算确认、占用和报价,异常却使仓储保持原样;重新构造仓储以后,同一请求仍可正常预留。取消也注入同类故障,原活跃承诺不会提前消失。
sequenceDiagram
participant U as 应用用例
participant W as 工作副本
participant R as 内存仓储
participant P as 发布端口
U->>W: 判断并修改日历和回执
W-->>U: 候选快照与变化事件
U->>R: BEFORE_COMMIT 故障点
Note over U,R: 抛错则旧快照不变
R->>R: 替换 committed
U->>P: AFTER_COMMIT 故障点
Note over U,P: 抛错时提交已经存在
U->>P: publish
这次整体替换跨过了设备、日历和草案的对象边界,是为了让小型教学应用具有一个明确的本地提交点。它不说明所有领域对象应该合并成一个聚合,也不要求后续数据库采用同一张表。事务回调若加入外部写操作,那些副作用不会被丢弃工作副本撤销。
单个仓储实例的锁也不会延伸到另一个仓储或进程。分别从同一快照恢复两个实例,会形成两个独立状态。本篇验证的是内存失败边界;数据库唯一约束、版本号和跨进程竞争需要另外的实现与实验。
事务接口在此属于受信代码接口。回调可以操作工作设备,却不应启动嵌套事务;实现显式拒绝嵌套调用,以免内部提交又被外层候选快照覆盖。同步读取维修结果会占用这把锁,实验不据此评价吞吐量,也没有把真实远程调用放进临界区。
领域事件尚未成为可靠消息
Evans 将领域事件用于表达领域中已经发生、值得追踪的活动,并与软件内部的系统事件区分。实验使用不可变的“预留已确认”和“预留已取消”,每个事件携带请求编号。DDD Reference,印刷页13
事件由成功变化产生,提交后才调用发布端口;业务拒绝、历史回放和重复取消不产生新事件。取消只释放活跃承诺,保留确认回执和合同草案。因此“取消成功”没有被偷换成退款完成,历史确认也没有被删除。
AFTER_COMMIT 故障点在提交完成后、调用发布端口之前抛错。实验重新读取仓储,合同和占用均存在,但事件列表为空;用新的应用实例重放原命令,仍只返回历史成功,不会补发。发布端口自身抛错也得到同样的已提交状态。
这个缺口直接限制了当前交付:没有消息代理、发送回执、持久事件表或 outbox,不能承诺可靠投递。把故障日志打印出来不会弥补事件丢失;若业务要求通知最终到达,需要新增持久待发记录、恢复流程与消费者契约,并重新验证提交边界。
发布还发生在仓储锁释放之后。多个调用之间的通知顺序没有额外保证,事件结构也没有外部订阅者所需的协议版本和去重标识。请求编号便于关联本地用例,但不能单凭这个字段就推断跨系统恰好一次处理。领域事实的命名与消息交付机制必须分别验收。
运行与后续接口
安装 JDK 21,将 JAVA_HOME 指向安装目录,在仓库或下载包根目录执行:
1 | |
本次运行使用 Corretto 21.0.11,以 --release 21 -Xlint:all -Werror 编译;原始基线48项、新增用例36项通过。原始输出和退出码位于 evidence/27,接口、快照结构、故障点与未实现边界冻结在 models/27/ARCHITECTURE-BASELINE.md。
下载 第27篇源码与证据包。后续替换仓储或发布机制时,保留全局请求回放、完整负载比较及取消历史,再为新增的持久化与消息保证补充场景;单纯换一个基础设施类名不会扩大这里的证据范围。






