企业应用架构04:模块化单体与上下文边界
维修检查通过后,租赁可以解除维修限制;是否接受一段新租期,仍由租赁规则决定。如果维修模块能直接拿到预约日历并删除记录,接口上的职责划分就没有约束力。企业应用架构03:分层与端口适配器 检查了内外依赖,本篇把同一进程中的维修、租赁与结算视图拆成可检查的模块。
模块边界要回答数据由谁修改
模块化单体仍然是一个进程,可以通过普通方法调用协作。它要求模块有明确入口、依赖方向和内部状态所有者。把包名改成 maintenance、rental、settlement,并不能阻止彼此读取内部集合;必须把预期边界变成编译或者构建能够检查的规则。
Fowler 在 Monolith First 中强调,单体内部仍需要留意 API 和数据存储的模块性。不能据此推导任意单体都能轻易拆分服务。本例也没有独立部署、独立数据库或进程故障隔离,采用模块系统是为了检查同一程序的源码访问关系。
限界上下文与 Java 模块也没有强制的一对一关系。上下文限定模型语言的含义,模块组织可编译的代码。一个上下文可以包含多个技术模块,多个小模块也可能共享一套领域语言。本章把维修与租赁作为语义边界,把结算实现缩小为只读结果视图;这不是完整财务上下文的实现。
| 模块 | 拥有的状态 | 公开协作入口 |
|---|---|---|
| 维修 | 设备对应的维修结果 | 报告维修结果、查询结果 |
| 租赁 | 设备、预约历史、报价与请求历史 | 申请租赁、取消申请 |
| 结算视图 | 本例没有独立账本 | 格式化公开报价结果 |
维修只能通过自己的命令更新维修事实。租赁读取维修结果后,负责转换成自身的限制语言。结算视图只能接收不可变返回值,不能取得租赁仓储。数据所有权在这里意味着谁能执行修改,不等于把某个对象的引用命名为“私有”就完成隔离。
用模块系统表达允许的引用
工程使用 Java 21 的命名模块。维修导出 api 包,内部 RepairStore 不导出;租赁导出 api 包,内部 RentalState 不导出。结算声明依赖租赁,只使用公开的结果类型。演示入口组装三个模块,执行一次连续业务轨迹。
flowchart LR
D[演示入口] --> R[租赁公开 API]
D --> S[结算视图 API]
S --> R
R --> M[维修公开 API]
R --> L[冻结租赁核心]
M --- MS[内部 RepairStore]
R --- RS[内部 RentalState]
箭头表示模块读取依赖。内部存储不是导出的入口,模块之间也没有反向箭头。Java 的 requires 声明模块读取关系,exports 控制包的公开范围;两者需要共同满足,public 类并不因此对所有模块可见。
公开 API 若在方法签名中使用另一模块的类型,本例用 requires transitive 传递读取关系,例如结算接收 RentalApi.Result。它只是让使用这个 API 的模块能解析签名中的类型,不会顺便导出租赁内部包,也没有授予修改租赁状态的权限。
冻结的租赁核心仍来自 00 的同一份源码。运行脚本将这些 Java 文件原样复制到临时模块源树,为它增加一个限定导出的 legacy 模块描述符,只向租赁模块公开所需包。副本字节与原清单保持一致,架构包装和业务修改因此可以区分。
这个兼容包装仍包含旧教学实现中的多种责任,不代表核心已经完全按模块拆开。它的用途是控制新模块从哪里进入旧代码。后续若迁移持久化,可以先保留公开用例契约,再逐步替换内部实现,不能为了图形整齐而绕过既有行为验证。
维修解除限制后,仍要走租赁命令
维修公开结果包括 WORKING、INSPECTION_FAILED 与 RETURNED_AFTER_PASS。前两者映射为租赁 BLOCKED,最后一种映射为 RELEASED。这个映射是当前教学契约,供应商状态扩展或检查规则变化需要重新确认;没有对应设备结果时抛出异常,不默认允许预约。
连续实验先报告设备正在维修,再申请租赁,得到维修限制拒绝。随后报告检查通过并归还,使用原请求号再次申请,仍得到原来的拒绝。请求历史先于当前资格判断,这是冻结基线的重要约定,维修变化不能把同一业务请求改写成另一个结果。
sequenceDiagram
participant C as 调用者
participant M as 维修模块
participant R as 租赁模块
participant S as 结算视图
C->>M: 报告 WORKING
C->>R: 申请,请求号 A
R->>M: 读取维修结果
R-->>C: 维修限制拒绝
C->>M: 报告 RETURNED_AFTER_PASS
C->>R: 回放请求号 A
R-->>C: 保留原拒绝
C->>R: 新请求号 B
R->>M: 读取维修结果
R-->>C: 确认与报价
C->>S: 展示公开结果
换用新的请求号后,申请成功。九十分钟按冻结的小时向上取整报价得到 25.00 CNY,结算视图只把返回值格式化显示。它没有扣款、退款、账本分录,也没有查询租赁内部 Map;把这条结果称作结算完成会超出代码行为。
同一区间的另一个新请求被重叠规则拒绝。取消已确认请求通过租赁 API 执行,随后回放该请求仍返回原确认与报价,证明取消释放占用但保留历史。维修模块从未直接修改日历,业务状态变更始终由状态拥有者处理。
四个非法依赖实际被拒绝
每个负例都先编译完整的正常模块图,确认源码与依赖齐全,再在临时目录注入非法 Java 文件或修改模块声明。原源码不会被负例改写,证据可以区分正常构建失败与专门的边界失败。
第一项让维修模块导入租赁内部状态并尝试取得仓储;第二项让维修直接引用旧核心 Equipment 并调用退役;第三项让结算读取 RentalState。它们分别碰到模块读取或限定导出的限制,编译器报告包不可见。不能通过把内部类声明为 public 来绕过这些检查。
第四项为维修模块增加反向的 requires rental,而租赁本来就依赖维修,模块图因此成环。编译器拒绝循环依赖。该结果不说明所有业务调用环都已消除:回调、消息或者反射可能形成不表现为模块声明的运行关系,仍需其他分析手段。
这些限制适用于当前构建和启动配置。显式增加 add-exports 等参数能够改变访问范围,所以模块系统不是敌对代码隔离或租户安全边界。当前调用者均可信,也没有认证和授权检查;不能把编译期封装当作数据访问权限控制。
公开结果需要承诺哪些稳定性
RentalApi.Result 只包含状态、金额数值和币种,没有把 Equipment 或预约集合传出去。调用方因此不能通过修改返回对象影响租赁仓储。金额与币种在结果中分开传输,但领域计算仍使用冻结的 Money 值对象;这份边界记录不承担跨币种运算,也不取代领域金额的不变量。
公开 API 仍然需要演化管理。例如增加一个拒绝状态,结算或页面消费者不能自动把所有未知状态当作成功。当前实验的消费者只格式化有报价的结果,没有覆盖跨版本消费者兼容性。模块编译可以发现签名不兼容,却不能判断同一个字符串的业务含义是否悄悄改变。
源码检查与语义检查因此分别留证:非法导入由编译器拒绝,请求回放、重叠与取消由场景断言检查。只运行其中一组,都不能覆盖另一组负责的契约。Evans 对限界上下文的要求也涉及模型含义的一致范围,不能缩减成是否导出了某个包。
协作的事务边界在哪里
维修报告与租赁申请是两个同步调用。报告写入维修模块自己的内存 Map,申请再读取其结果,没有跨模块原子事务。测试用固定顺序执行,尚未覆盖维修状态在读取后、租赁提交前被其他线程更新的交错。
租赁一次命令仍使用原有内存快照事务,计算失败时不提交候选状态。这个局部提交边界不会撤销之前已经完成的维修报告。将来若要求二者满足跨模块一致性,需要先明确业务容忍的窗口,再选择协调方案,而不能由同进程调用自动推出原子性。
本章组装给事件发布端传入空回调,范围仅是模块访问与业务协作。原基线的发布断点检查仍运行,但模块实验没有新增持久消息、自动补发或重启恢复能力。结算视图读取本次返回值,也没有模拟异步消费者或持久投影。
复跑与证据
准备 JDK 21 和 Python 3,在仓库根目录或解压根目录执行:
1 | |
末尾分别追加 --state-write、--device-write、--settlement-read 或 --cycle 可运行四个预期失败入口。本章实验包 保留仓库目录前缀,包含冻结源码及散列清单。正常执行原 48 项、应用 36 项和新增 7 项检查,退出码为 0;四个负例均被编译器拒绝,退出码为 1。
环境、原始标准输出、错误诊断与调用参数保存在 evidence/04/。所有编译使用 --release 21 -Xlint:all -Werror。这组证据确认了实际模块图与选定协作轨迹;数据库所有权、跨连接事务和进程重启后状态需要后续真实持久化实验,当前内存结果无法回答。






