命令行可以发起预留,自动化测试也可以发起预留。如果两个入口各自检查维修状态、计算金额、保存日历,它们很容易在新增规则时分叉。把相同逻辑复制两遍以后,再给其中一个类取名 Service,并不能形成共同的应用边界。

本篇在 固定的租赁应用 外提供同一个服务入口,由命令行和检查程序分别调用。实验实际注入计算完成但保存尚未发生的异常,再绕过服务直接调用日历,观察哪些规则随之丢失。

服务边界描述一次应用操作

PoEAA 的 Service Layer 从调用方角度定义应用提供的操作,并协调每次操作的响应。公开模式页署名 Randy Stafford;多种界面重复组织同一交互逻辑,是该模式要处理的问题之一。Service Layer 原文摘要

租赁申请不是单独调用一次金额乘法。它需要识别历史请求,取得设备与维修评估,验证时间,创建报价草案,判断占用,再保存结果。输入来自命令行还是测试,不应改变这些步骤的业务含义。

冻结基线中的 RentalApplication 已承担这项协调。本章的 RentalService 是显式门面,复用它的行为,并把回执状态与已存报价组成调用结果;没有重新复制预留算法,也没有再建立一套表面相同的服务层规则。

flowchart LR
    CLI[命令行参数] --> S[RentalService]
    T[自动化检查输入] --> S
    S --> A[冻结RentalApplication]
    A --> M[维修评估端口]
    A --> F[租期规则与合同工厂]
    A --> C[预留日历]
    A --> R[仓储提交]
    R --> P[提交后事件发布]

这张图表示调用责任,省略网络与数据库。当前命令行和测试都在本机进程内执行,维修结果由明确的教学输入提供。类之间画出端口,不表示已经存在独立服务,也不说明它们需要分别部署。

输入与输出不能只剩成功布尔值

apply 接收完整预留命令及当前时间,返回状态和可选报价。确认时,报价来自已提交的合同草案;业务拒绝没有草案时,报价为空。不能用零元代替不存在的报价,因为合法免费与没有报价属于不同含义。

完整命令包括请求编号、设备、租期、费率和修订号。相同请求重放返回历史状态与原报价,当前时间变化不会使它重新计价。这里继续使用冻结基线的小时计费,分段日价实验 尚未接入本用例。

cancel 只接收请求编号,对活跃承诺执行释放。它不会删除成功历史或合同草案,也没有退款副作用。取消后的原请求重放仍得到历史确认与报价,但不会再次形成占用;服务返回结果必须与这些时间语义一致。

故障还要区分业务拒绝与技术异常。维修受限是已完成判断的业务结果,会保留回执;保存失败则没有完成提交,调用方收到异常。把所有情况包成 false,会使调用者无法判断应该换编号重试,还是重放同一个未完成请求。

领域行为继续由原对象负责

服务知道何时需要创建草案,却不重新实现报价乘法和租期规则。工厂验证创建条件,金额类型保持精度与币种约束,日历判断同设备窗口重叠。服务所增加的是调用协调与外部结果形状,不是把领域对象变成只带字段的数据袋。

本章构造器只注入实际可替换的依赖:仓储、维修端口、发布端口及租期策略参数。测试提供固定时钟和维修结果,命令行也明确选择输入。没有为每个值对象建立一个接口,也没有为了使用依赖注入而引入容器。

应用服务与领域服务因此承担不同工作。报价计算可以独立于仓储运行;应用操作必须决定何时采用这个结果、是否形成承诺以及何时通知。把两者都叫 Service 时,仍应通过输入、副作用与依赖判断责任,而不能只看文件名。

在计算后保存前抛出真实异常

AfterCalculationFailure 包装仓储端口。它把操作交给真实工作会话执行,等预留、报价和回执候选已经计算完,才抛出异常,阻止内存仓储接收最终候选快照。它不是在入口处直接报错,也不是只打印一条模拟故障日志。

检查程序同时断言三件事:用例回调完成计数为一,重新读取的仓储快照等于初始状态,事件集合为空。只有看到这些二值结果,才能把故障定位到计算以后、提交以前,而不是把任何异常都归类为回滚成功。

sequenceDiagram
    participant I as 服务调用方
    participant S as 应用服务
    participant W as 仓储工作副本
    participant C as 已提交快照
    I->>S: 申请预留并报价
    S->>W: 执行真实规则与候选变更
    W-->>S: 候选结果已形成
    Note over S,W: 注入保存前异常
    S-->>I: 抛出失败
    I->>C: 重新读取
    C-->>I: 与初始状态相同

图中的“已提交快照”仍是内存对象。工作副本隔离使候选变更可以丢弃,但没有执行 SQL,也没有另一条数据库连接读取。这个实验只证明当前实现的协调次序和内存失败边界,数据库原子性需要后续真实存储实验。

相反,若故障发生在提交之后,结论就不同。冻结应用已经保留占用与回执,发布失败不会回滚它们;历史回放也不会自动补发。服务门面没有通过捕获异常改变这条事实,也没有把一个统一入口包装成可靠消息方案。

绕过服务会遗漏什么

反例使用同一个 E1:设备已经退役,维修状态为 BLOCKED,租期只提前一秒。经服务调用,设备资格检查首先返回 INELIGIBLE,并留下明确的业务拒绝回执。其他条件也不满足,但既有拒绝优先级保持不变。

随后把同一请求直接交给一个预留日历。日历没有已有占用,于是返回 CONFIRMED。它既不知道设备退役,也没有读取维修结果或执行提前量规则;这是日历负责的范围,不是日历算法突然失效。

绕过路径也没有生成应用合同草案和合法确认回执。一个局部对象报告确认,只能说明它完成了自己的判断,不能代表整个租赁申请完成。若界面把这项结果直接展示为业务成功,就把较窄的保证扩大了。

当前代码仍允许调用者获得这些公共领域类型,因此服务是调用约定,并非不可绕过的编译或安全边界。本篇实际展示漏洞形状,后续依赖和模块检查才会进一步限制访问路径;没有把约定已经写入文档当成违规代码无法编译的证明。

两个入口共享同一操作

正常测试申请一个九十分钟租期,按每小时十二点五元向上取整,得到二十五元报价。保存后有一条回执、一份草案和一次确认事件;相同输入重试得到相同结果,事件数不增加。取消再重放也保持历史结果。

命令行接收编号、设备、相对固定时钟的开始秒数、持续秒数和维修评估值。它只做参数解析和组装,再调用相同服务。合法输入输出确认与二十五元,BLOCKED 输入输出维修拒绝和空报价,没有另写一套命令行业务判断。

每次命令行运行都会创建新的内存仓储。退出进程后数据丢失,因此不能用两次独立运行宣称跨进程幂等。重复请求与取消历史由同一仓储生命周期内的自动化检查验证,入口日志与这项范围说明应一起阅读。

参数解析失败也不应被算成业务拒绝。非法数字或未知维修枚举会在进入服务前失败;本章没有提供 HTTP 状态码、表单提示或身份授权。这些界面协议仍需要自己的输入边界,不能直接把 Java 异常名当作对外协议。

接口少并不意味着边界含糊

服务接口不需要同时暴露“保存日历”“写合同”“发确认”三个步骤让界面任意组合。调用方需要的是一次明确的申请操作,而内部如何计算和保存由应用协调。暴露过细会把相同顺序要求重新分散到每个入口。

接口也不宜无限扩大成一个处理所有事务的万能方法。取消与申请的输入、历史含义和副作用不同,分别命名更容易写出场景检查。以后增加延期或退款,需要先说明独立意图和失败语义,再决定是否复用现有协调步骤。

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

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

命令行正例可执行 bash examples/enterprise-application-architecture/run-lab.sh 02 cli cli-request E1 7200 5400 RELEASED。实际 Corretto 21.0.11 严格编译通过,原始48项、累计应用36项以及本章11项检查通过;命令行正反例另存输出。

下载 第02篇服务入口与故障实验包。输入、返回值、副作用及限制记录在 models/02/contracts.md,运行环境、命令与原始失败观察位于 evidence/02。这些材料证明本机用例边界,尚不包含数据库或跨服务保证。