软件建模26:Domain Service、Specification与Factory
租期至少提前一小时、最长四十八小时;报价按不足一小时向上取整;合同草案必须保存与租期及单价一致的金额。这三条教学规则都能写成条件语句,但放在哪里,会决定调用者能否绕过规则,以及修改一种政策会牵动哪些对象。
Aggregate与一致性边界 已把资源排他放进预留日历。本篇处理创建之前的规则、报价和完整性,直接复用既有 `EquipmentId`、`Period` 与第24篇的 `Money`。创建成功只表示一份内部一致的草案,不意味着已经预留、签署或付款。用问题选择职责
“这份租期符合规则吗”需要一个候选和一组政策参数;“这份租期按当前单价是多少钱”需要计算;“能否交出一份完整草案”需要把前两者组合起来。它们的输入、失败方式和副作用不同,可以分别建立契约。
Eric Evans 在《DDD Reference》2015年版 Services 中讨论了不自然属于实体或值对象的领域操作;Factories 则关注完整对象的创建与内部约束。原文,印刷页14、18 这些职责不要求每个类都配一个接口,也不要求引入依赖注入框架。本例采用几个可直接构造的 Java 类。
flowchart LR
A[应用提供请求 单价 当前时间] --> F[RentalContract.Factory]
F --> S[RentalPeriodSpecification]
F --> P[PricingService]
S --> V[是否满足及失败原因]
P --> M[Money 报价]
F --> C[不可变 RentalContract 草案]
C --> O[covers 同币种足额判断]
A -. 后续独立调用 .-> R[ReservationCalendar.reserve]
图中的日历没有成为工厂依赖。工厂只回答能否构造草案,预留日历负责资源冲突。若在工厂内部顺便占用资源,调用者连“试算并展示草案”也可能产生承诺,失败恢复范围会随之扩大。
Specification 保存可重复判断
Evans 与 Martin Fowler 的《Specifications》将匹配条件从被匹配的候选中分离,并讨论校验、筛选和按条件创建等用途。原文,第1—3页 本例只实现带参数的租期判定,没有布尔表达式解释器或通用规则引擎。
RentalPeriodSpecification 保存最小提前量和最大时长。evaluate(period, now) 依次返回 TOO_SOON、TOO_LONG 或 SATISFIED;isSatisfiedBy 提供同一规则的布尔表达。若两项都不满足,当前合同优先报告提前量,不宣称一次返回全部错误。
时间作为参数传入,使相同候选和相同时间产生相同判断。实验固定当前时间为2026年10月3日 UTC 八点:九点开始恰好通过,八点五十九分五十九秒开始失败。四十八小时可通过,再多一秒被拒绝。负提前量与零最大时长在政策构造时就被拒绝。
租期自身仍由 Period 保证起点早于终点。政策检查不重复定义空区间,却增加当前上下文所需的提前量和时长上限。这种分工让通用时间区间可以用于历史记录,而“当前允许新建”的条件仍留在新建规则中。
这里的判断不查看设备占用,所以它不会因查询而产生承诺。即使先调用规格得到真,稍后真正预留时仍须执行日历内的冲突检查。将纯规则与会变动的共享状态分开,有助于识别哪些结果可以重算,哪些结论必须与写入一起完成。
参数化规格适合当前两项能独立描述的政策。假如后来出现“周末设备只能连租两天”,应先补齐周末按哪个时区、两天按历日还是时长等含义,再决定是否增加新规格。直接提供任意条件拼装接口,既不会回答这些问题,也会让没有来源的规则组合变得更容易。
当前规格返回一个原因,调用者可以据此显示具体拒绝信息,也可以只使用布尔结果做筛选。两种调用共享同一实现,避免列表页说可租、提交页却按另一套提前量判断。若未来确实需要一次显示多个原因,再修改结果类型及调用方;当前没有提前加入组合树、解释器和数据库查询翻译。
报价可以是无状态的领域操作
PricingService.quote(period, rate) 使用租期和 HourlyRate 计算总额。本例单价为每小时12.50元,九十分钟计两小时,因此返回25.00元。整一小时返回12.50元;超过一小时哪怕只有一纳秒,也按两小时计算。这是教学政策,不能据名称推断真实商家的收费习惯。
实现先按秒计算整小时,再判断剩余秒或纳秒是否大于零。这样不会把六十分钟加一纳秒截断为六十分钟。金额使用第24篇的 Money,以 BigDecimal 乘整数小时,保留币种;没有浮点误差修补、换汇或隐含舍入。
Money 允许负金额,是为了表达金额值的通用范围;本章 HourlyRate 额外拒绝负单价,允许免费单价。相同数据类型进入不同职责时,可以承担更窄的约束。把“单价不能为负”直接塞进 Money,会无故限制别处的调整金额表达。
单价还有非空白的修订标签。草案保存所用单价与标签,因此新建另一份使用新版单价的草案,不会改变旧草案的报价。标签本身不验证价格目录:两个来源若给同一标签提供不同金额,仍需目录或应用边界处理,本类没有伪装成定价系统。
这段计算完全可以写成一个纯函数。采用命名服务,是为了把“按小时报价”作为明确的领域操作供创建流程调用;代码没有可变状态,也不需要框架托管。若将来只有一种简单单价,放成值对象的方法同样可能合理,不能只凭类名判定设计优劣。
对象方法、纯函数和服务也不是三个互斥阵营。报价服务的方法在行为上就是纯计算;规格对象保存政策参数,其判断仍无副作用;草案的方法使用自身不可变金额。职责判断关心的是输入来自哪里、规则归谁维护以及哪些状态能变化,不是统一把所有计算迁进某个后缀相同的类。
Factory 交出完整草案
RentalContract.Factory.create(request, rate, now) 先执行租期规格,再计算报价,最后通过私有构造器建立对象。外部没有公开构造器可以任意塞入一个金额,也没有能拆开设置租期、单价、总价的 setter。正常返回时,这三者必须一致。
sequenceDiagram
participant A as 应用
participant F as Factory
participant S as 租期规格
participant P as 报价服务
participant C as 合同草案
A->>F: create(request, rate, now)
F->>S: evaluate(period, now)
alt 不满足
S-->>F: TOO_SOON 或 TOO_LONG
F-->>A: 抛出异常
else 满足
S-->>F: SATISFIED
F->>P: quote(period, rate)
P-->>F: Money
F->>C: 私有构造及完整性校验
F-->>A: 不可变草案
end
保留一个 covers(offered) 对象方法,可以观察职责是否被服务搬空。这个问题由草案自身的总价回答:同币种且金额足够才返回真,币种不同直接拒绝。报价服务负责生成总价,草案负责使用自身金额判断;方法返回真仍只说明报价足额,没有记录付款事实。
实验用25.00元、24.99元及25美元分别调用该方法,得到真、假和异常。若将其改成一个读取所有草案字段的通用服务,虽然也能算出答案,却会把对象自己的规则移到外部;当前没有支持这种转移的场景。
恢复与新建面对不同问题
草案提供不可变 Snapshot 和 restore。快照构造时重算保留单价下的报价,拒绝金额或币种不一致;恢复后的快照与原快照相等。这里验证的是内部一致性,并没有认证输入来源或签署权限。
恢复不会用今天的时间重新跑“一小时前下单”规则。昨天合法建立的草案,今天可能已接近开始时间;如果恢复等同新建,读取历史对象就会失败。相反,新请求必须经过工厂,不能把可信恢复入口当成绕过新建政策的公开命令。
当前草案没有保存原规则版本,也没有独立法律合同编号,创建请求编号只承担实验的请求关联。真实应用若需要解释当时为何允许创建,应持久化相应规则和决策依据。报价标签只能说明单价来源,不能替代全部审计信息。
合法草案之间仍然可能冲突
实验创建两份同设备、租期相交的草案,两份都符合提前量和报价规则。创建结束后日历仍为空;将它们依次送入第25篇的日历,第一份确认,第二份拒绝。这个差异直接区分了“规则允许创建”和“资源承诺已经取得”。
设备是否在役、维修限制、请求重放的优先级也没有放进本章工厂。应用接入时应先识别已有回执,再决定是否执行随当前时间变化的新建检查,否则同一请求可能因为晚了一小时重试而改变结果。完整约束写在 examples/software-modeling/models/26/contracts.md。
运行这组职责检查
在仓库根目录执行 JAVA_HOME=/path/to/jdk21 examples/software-modeling/labs/26/run.sh。脚本以 Java 21 的 --release 21 -Xlint:all -Werror 编译,直接使用第24篇金额类和第25篇日历;没有复制另一套金额或预留实现。
本次原始日志包含基线48项、本章27项检查,正常入口退出零。附加 invalid-period 会通过公开工厂创建提前量不足的请求,抛出 TOO_SOON 并退出一。日志、输入常量与源码共同保存在 examples/software-modeling/evidence/26/ 和 labs/26/;这组标准库实验覆盖本章规则,不提供支付、持久化或远程调用保证。






