租赁费用只有“天数乘单价”时,一个过程就能清楚表达。价格在租期中途变化以后,报价与延期两个入口都要理解有效期;如果报价改成分段收费,延期仍按起租日单价算全期,同一份租期就会出现两个答案。

本篇用 Transaction Script、Domain Model、Table Module 分别实现相同计费输入,实际运行旧规则、新规则和漏改入口的反例。固定租赁基线 保持不变;这里的整日分段价格是隔离的新教学规则,没有偷偷替换上游的小时计费。

先把价格变化说清楚

输入是整日的 LocalDate 半开区间。十月一日至五日每日一百元,五日至十日每日一百五十元;结束日不计费。这里没有把日期转换为时区瞬间,也不涉及夏令时、按小时补差或跨币种换汇。

旧规则以起租日费率计算全期:三日至六日共三天,收费三百元。新规则按租期落入的有效段计算,三日和四日各一百元,五日一百五十元,共三百五十元。这是业务结果的改变,不能称为仅调整结构的等价重构。

延期输入由旧租期与新结束日组成,返回重新计算的全期总额。三日至五日延期到七日,新总额为五百元;它不是新增应收三百元,也没有执行预留或实际收费。缩短结束日不属于此接口的延期行为,会被拒绝。

flowchart LR
    P[租期 三日至六日] --> A[与一日至五日相交]
    P --> B[与五日至十日相交]
    A --> C[两天乘一百元]
    B --> D[一天乘一百五十元]
    C --> T[总价三百五十元]
    D --> T

图回答分段报价怎样组成,省略占用、付款和存储。价格行不得重叠,币种必须一致;租期覆盖不到有效价格时应拒绝,而不是把未知日当成免费。无效输入的处理也是三种实现共同遵守的规则。

Transaction Script 把请求写成过程

PoEAA 的 Transaction Script 以处理一个请求的过程组织业务逻辑,并允许把公共子任务提取为子过程。这里的“Transaction”不意味着代码只要采用这种组织就自动获得数据库事务。Transaction Script 原文摘要

QuoteScript.quote 逐日遍历租期,在价格行中找到覆盖该日的一行,累加金额;缺少覆盖就抛出异常。extend 检查新截止日,再调用同一个报价过程。调用路径短,规则变化的位置容易定位,也没有为每个条件建立接口。

旧版过程只寻找起租日费率,然后乘总天数。新版修改报价循环,延期通过调用同一过程获得新规则。如果另写一份延期算式,就出现两个修改点。实验的遗漏版本真实执行旧延期算法:报价已经是三百五十元,延期却仍返回四百元。

这种重复不是脚本形式必然造成的。规则少、入口少、公共计算能明确复用时,脚本完全可能是最合适的实现。实验暴露的是两个入口各自复制算法的风险,不能据此宣布所有过程式代码都无法维护。

Domain Model 让数据与行为靠近

Domain Model 将领域数据与行为放在对象模型中。公开摘要强调对象所代表的业务含义及其协作;它没有把“类越多”作为衡量模型的办法。Domain Model 原文摘要

本实验中,Segment 持有一行有效期价格,自己计算与租期相交的天数以及该段收费。DomainPricing 汇总各段,并检查覆盖天数等于租期天数。覆盖和计费两项行为围绕同一段价格组织,减少调用者自行解释字段的需要。

旧领域版本只通过价格对象取得起租日费率。新增分段以后,价格段承担相交计算,报价对象负责完整性与合计;差异保留在可编译的 before 目录和真实 diff 中。延期仍复用报价,没有另行修改费率选择算法。

这份模型很小,没有为教学代码补造合同实体、支付服务或继承树。它采用已冻结的 Money 保持币种和小数精度,但没有把所有计费决策塞进金额对象。金额负责数值约束,价格段负责有效期与收费的关联。

Table Module 以行集组织行为

Table Module 的一个实例处理表或视图中多行数据的业务逻辑,区别于每个订单各有一个领域对象。这个组织单位决定调用者通常需要提供行标识,而不是持有某个业务对象引用。Table Module 原文摘要

QuoteTable 持有费率行集与租赁行集。quoteRow 根据租赁编号取得区间,遍历费率表计算相交天数;extendRow 同样从行集中定位记录,再计算新总价。同一个模块实例实际处理两条租赁记录,避免把单对象包装类误称为表模块。

本章使用不可变内存行集,没有数据库连接,也没有生成 SQL。因此它只是展示 Table Module 的职责组织,不足以证明关系存储往返、约束或事务行为。表形状与数据库真实性是两回事,真实持久化需要后续单独运行。

flowchart TB
    I[同一报价与延期输入] --> S[脚本过程]
    I --> D[价格段对象与报价对象]
    I --> M[操作多行的表模块]
    S --> A[逐日选择费率]
    D --> B[对象计算覆盖和收费]
    M --> C[遍历行集累加交集]
    A --> O[相同金额或相同拒绝]
    B --> O
    C --> O

图比较计算责任的位置,不比较性能。脚本逐日遍历,另外两种按价格段计算,本实验没有基准测试,不能从循环层次就报告吞吐量结论。若租期数量或价格段数量变化,应另建带环境和规模的测量。

两类变化分别留证

before 目录里的三个旧实现可以独立编译,它们按旧规则通过二十五项检查。根目录三个新实现则通过二十八项,新增覆盖缺口拒绝。输入与期望由同一个检查程序控制,结果不能因实现名称不同而调整。

QuoteScript.diff、DomainPricing.diff、QuoteTable.diff 保存实际源文件差异。脚本主要改变报价循环;领域版本把起租日查价转为价格段覆盖与收费;表模块改为按行累计区间交集。这个记录说明哪里改变了,不把修改行数直接换算为维护成本排名。

旧规则若接受新规则验收,三种实现中首先运行的跨有效期场景会失败。它返回三百元,而新契约要求三百五十元,进程退出一。这证明断言确实区分了两个业务版本,没有把“能编译”误当成规则迁移成功。

第二个负例只遗漏脚本的延期入口。它先成功执行新版报价,再按旧起租日费率计算延长后的全期,得到四百元;契约要求五百元,延期断言失败。失败点不同于旧实现整体失败,因此能够定位“一个入口没改”这种局部不一致。

边界比常规金额更重要

价格在五日切换,四日至五日只能使用旧价,五日至六日只能使用新价;结束端点不属于租期,不能把五日收两次。三种版本都要接受同一组边界检查,分段公式不能只凭一张时间轴判断正确。

负租期、零租期和倒退的延期均被拒绝。费率行重叠会产生多重匹配,因此在创建报价器时拒绝;费率行有缺口则在具体租期需要那段价格时拒绝。这样可以区分价格表本身冲突和某个请求超出覆盖范围。

币种错误同样不能靠金额相等掩盖。两个一百元字符串若分别代表人民币与美元,不能组成同一个总价。三种实现都复用金额类型,并在价格表入口要求同币种;没有为了让对照通过而忽略币种或自动寻找汇率。

延期返回总额的约定也应写进调用方测试。原报价两百元,新报价五百元,差额是三百元;若界面误把总额当追加款,即使三种计算器完全一致,收款用例仍会错误。本实验止于报价结果,不用三种版本互相一致来证明未实现的收费流程。

选择与实验边界

如果报价规则简单且只在一个请求中使用,脚本已经足够清楚。若有效期、折扣或费用构成逐渐形成稳定概念,对象行为可以减少字段解释散落。若现有应用主要处理表格行集,表模块可能更贴近它的数据入口。这些是结合当前结构的选择条件,不是三种模式的固定等级。

本章没有把分段日价接进冻结预留应用,没有迁移既有合同,也没有重算历史收费。这样可以把规则增加和组织方式比较单独验收;真正接入时,还要决定旧回执、价格版本与延期占用怎样共同演化。

使用 JDK 21,在仓库或下载包根目录运行:

1
bash examples/enterprise-application-architecture/run-lab.sh 01

同一入口加 --old-contract 或 --omit-extension 应退出一。实际使用 Corretto 21.0.11,严格编译;原始输出、失败断言和三份差异在 evidence/01。下载 第01篇三种实现与反例包,可与第00篇累计检查一起复跑。