租赁申请服务已经能协调资格、维修结果、预约日历与报价。如果把维修读取从内存函数换成文件,用例是否还需要修改?如果显示页面增加一个字段,领域代码是否跟着引入页面对象?这两个问题决定依赖边界是否有用。企业应用架构02:Service Layer 与用例边界 确定了服务入口,本篇为其中一个外部依赖提供真实替换,并让越界引用在编译时失败。

四种组织方式分别关注什么

分层架构把不同责任放入不同层。Evans 在《DDD Reference》的 Layered Architecture 条目中要求把领域模型与界面、应用协调和基础设施分开。层次说明责任,但仅有目录名不能阻止领域对象调用数据库工具类,还需要规定允许的引用方向。

Cockburn 的 Hexagonal Architecture 强调应用内部与外部的区别。端口表达一次有业务含义的交互,适配器负责把外部技术转换成应用能使用的形式。同一端口可以有多个适配器;六边形的边数不要求恰好六个。原文也用内存与文件等替代实现说明应用可以脱离最终设备运行。

Palermo 的 Onion Architecture 把领域模型放在中心,内侧声明所需接口,基础设施在外圈实现;依赖指向中心。Martin 的 Clean Architecture 同样要求源码依赖向内,并进一步区分实体、用例、接口适配器和框架。运行时控制可能调用外部实现,源码仍然可以只依赖内部接口。

组织方式 主要关注点 本例需要检查的事
分层 职责分离与层间关系 领域规则是否混入界面或存储细节
Hexagonal 应用与外部通过端口交互 换维修读取方式是否改用例
Onion 领域居中、依赖向内 接口是否跟随内侧需要定义
Clean 策略层次和依赖规则 外侧数据格式是否进入用例

这些方法存在交集,不能仅按画成矩形还是圆形区分。本次采用端口适配器结构,因为维修读取已经有第二种实际实现。没有为日期运算、金额加法额外增加接口,也没有声称一次改造同时实现四套完整架构。

先确定端口说哪一种语言

冻结的租赁应用已有 MaintenancePort,按设备身份返回租赁侧的维修限制。RELEASED 表示解除维修限制,仍需检查设备是否处于 ACTIVE、租期是否合规以及区间是否已承诺给其他请求。把 RELEASED 直接解释为“可预约”,会丢掉这些独立条件。

本章文件格式为 E1=RELEASED 或 E1=BLOCKED,它已经使用租赁语言。真实供应商可能返回维修工单状态、检查结果和协议版本;那一层翻译仍需单独的防腐层,本实验没有把任意供应商文本直接当作可信资格。

新增 RentalUseCase 调用上一章服务,接收仓储、维修端口、发布回调与租期规格。文件实现 FileMaintenance 每次评估实际打开文件。用例不知道路径,也不导入 Properties;组装处选择内存函数或文件对象。

flowchart LR
  C[命令或测试入口] --> U[RentalUseCase]
  U --> S[RentalService]
  S --> A[冻结的租赁应用]
  A --> P[MaintenancePort]
  F[FileMaintenance 文件适配器] -.实现.-> P
  M[内存函数适配器] -.实现.-> P
  F --> D[维修结果文件]

图中的实线表示调用或使用关系,虚线表示实现接口。应用执行时可以调用文件适配器,但内层编译只需要端口声明。控制流向外并不要求业务源码引用外侧具体类,这正是接口在这里承担的工作。

同一请求,比较结果、状态与事件

实验创建两个内容相同的内存仓储:一个使用固定返回 RELEASED 的函数,另一个使用真实临时文件。两个入口提交相同请求,分别断言返回结果、最终快照和事件列表相等。只比较一行“成功”不足以排除一个实现多保存或多发布的情况。

接着把文件改成 BLOCKED,再提交新的请求号,结果成为维修限制拒绝。这一步确认适配器确实重新读了外部文件;如果实现把初始化时的值永久缓存,断言就会失败。这个结论只覆盖当前同步文件读取,不说明远程接口的新鲜度或缓存策略。

把内容改成未知状态后,枚举转换抛出异常,仓储快照保持不变。适配器不能把未知状态吞掉后默认放行。这里的失败仍是技术输入失败,没有擅自转换成新的业务拒绝代码;调用方需要保留异常或在明确的边界映射它。

最后删除文件,再回放第一次成功请求,仍返回原结果。冻结契约规定请求历史优先于当前资格,已经确认的请求不会因为维修来源暂时不可读而重新判定。新请求读取缺失文件会遇到文件访问异常,历史回放的成功不等于依赖整体可用。

让依赖方向能够失败

目录结构本身没有约束力。run.sh 先编译冻结核心、服务和用例,输出到 core 目录;随后以 core 为 classpath 编译适配器。第一步没有外侧类型,因此内层不能通过正常编译获得文件实现或显示 DTO。

flowchart TB
  I[内层源码:端口、服务、用例] --> IC[严格编译到 core]
  O[外层源码:文件适配器、显示 DTO] --> OC[以 core 为依赖编译]
  IC --> OC
  N[负例:内层导入文件实现或显示 DTO] --> G[仅使用 core 的编译检查]
  G --> X[外侧类型不可见:拒绝]

两个负例分别让内层类导入 FileMaintenance 和 DisplayDto。执行负例前,正常适配器已经成功编译,因此证据不是“工程少了文件”,而是规定的内层依赖集合不包含外侧类。编译器拒绝这些源码,退出码为 1;正常入口退出码为 0。

需要保留这个构建顺序。若以后把所有源文件一次放到相同 classpath 编译,原来的隔离就可能失效。检查应随代码提交执行,而不能只在文章里展示一次成功。这里也没有建立运行期安全沙箱,反射或特意修改构建参数属于另一种约束范围。

接口的形状也会泄漏依赖

把方法参数改成一个接口,并不自动消除外部耦合。假如端口返回文件行号、页面颜色或者某个驱动的异常类型,用例依然必须理解外侧格式。当前端口只返回租赁需要的限制结论,文件路径、字符读取和枚举解析都留在适配器中。显示负例则检查另一端:页面结果对象也不能成为内层参数的捷径。

端口是否属于内侧,可以从变更原因判断。新增一个供应商状态时,应先明确其业务含义,再修改翻译;新增一种租赁限制时,才可能要求扩展内侧契约。如果每次文件字段变化都迫使用例重编,接口只是搬运了技术字段,没有隔离这些变更。这是设计判断,当前有限测试没有穷举所有未来字段演化。

文件读取还说明失败策略必须在边界上说清。无法读取不是已经确认禁租,未知值也不是已经确认放行。本例选择传播异常并保持快照不变,使调用者能够区分业务判断与依赖故障。是否允许使用上一次有效结果、有效期多长、谁承担过时风险,都需要新的业务约定,不能由适配器自行决定。

测试与生产组装也有不同职责。测试可以创建内存仓储并收集事件,组装层可以读取配置选择文件实现;领域代码不应为了方便测试而搜索本机文件路径。构造时显式传入依赖,使这些选择可见,但传参本身不证明依赖生命周期、线程安全或资源管理已经正确。

本章文件适配器用资源管理语句关闭读取器,避免一次请求留下打开句柄。两个仓储各自保存快照,比较过程不会共享同一可变仓储而产生虚假的一致性。文件修改和调用由测试串行安排,因而结果也没有覆盖读取中途被另一个进程改写的情况。

可复跑结果与剩余边界

准备 JDK 21 和 Python 3,在仓库根目录或保留目录前缀的解压根目录执行:

1
bash examples/enterprise-application-architecture/labs/03/run.sh

负例分别在同一命令末尾加 --storage-leak 或 --display-leak。下载入口为 本章实验包。实际运行沿用 Corretto 21.0.11,编译开启 --release 21 -Xlint:all -Werror;原始 48 项、应用 36 项与新增 6 项检查通过。原始输出、失败诊断和环境记录位于 evidence/03/。

冻结应用原先把内存仓储等教学实现放在同一文件,本章没有迁移所有旧类。能够证明的是新增用例与文件适配器之间的编译方向,以及两种实现下选定场景的契约一致。副本每次运行仍核验来源散列,不能用移动类的方式暗中改变基线。

文件 I/O 是实际外部适配边界,却不是数据库事务、网络故障恢复或重启后持久性的证据。只有出现新的适配需求,端口的替换成本才有具体比较对象;本例已经比较了内存与文件,继续增加没有消费者的抽象不能扩大上述结论。

参考资料