软件建模22:子域与限界上下文
设备 E1 已归还,租赁操作员可以说“完成了”。维修人员刚更换零件,也可能说“完成了”,但质量检查还未通过。结算人员面对 120 元未付账款,同样不能把这笔业务标为完成。三个完成若共用一个状态,设备可能提前被放行,债务也可能被错误关闭。
子域帮助识别业务需要解决哪些问题;限界上下文规定某个模型和语言在哪个范围内成立。本篇在同一组租赁事实下运行两种边界方案,比较它们如何保持这些“完成”的不同含义。
业务问题与模型边界分别描述
Eric Evans 的《DDD Reference》要求明确模型适用的上下文,并在边界内保持概念与术语一致;描述边界时需要考虑团队、应用范围及代码、数据等实际表现。上下文地图还要记录模型之间的接触点和翻译。DDD Reference,2015,印刷页2、29
本例按业务问题分出租赁、维修和结算三个子域。租赁关心约定、交付与归还;维修关心故障处理和质量放行;结算关心应收、收款和未结金额。这种分析首先描述业务能力,不提前指定进程、数据库或服务数量。
解决方案可以为这些问题建立不同范围的模型。方案 A 把租赁和维修放入设备运营上下文,结算单独建模;方案 B 分出租赁、维修、结算三个上下文。子域划分没有因为这次代码组织选择而改变,两个方案也不是原书给出的设备租赁标准答案。
flowchart TB
subgraph P["问题空间:业务子域"]
R["租赁:占用与归还"]
M["维修:处理与放行"]
S["结算:应收与收款"]
end
subgraph A["方案A:两个上下文"]
OP["设备运营模型"]
BILL["结算模型"]
end
subgraph B["方案B:三个上下文"]
RC["租赁模型"]
MC["维修模型"]
BC["结算模型"]
end
R --> OP
M --> OP
S --> BILL
R --> RC
M --> MC
S --> BC
第一张图表示问题与模型的覆盖关系,没有把子域画成运行时服务。一个上下文覆盖两个子域,需要形成足够一致的模型;把两个互相矛盾的 completed 字段机械放进同一个包,并不能完成这种整合。
为三个完成写出不同条件
实验先声明新的教学假设:E1 归还后需要维修;维修作业结束以后,还要一次质量检查;结算初始应收固定为 120 元,分两次收取 50 元和 70 元。金额使用整数元,不涉及税费、优惠、冲正与支付渠道。
| 使用范围 | 完成的含义 | 尚不能推出什么 |
|---|---|---|
| 租赁归还 | 已接收到归还设备 | 不表示检查合格 |
| 维修作业 | 维修步骤已结束 | 不表示已获质量放行 |
| 结算 | 已付金额等于应收 | 不表示设备是否可租 |
“可租”在这次实验中也有明确限制:设备已归还且质量检查通过。它只覆盖这两个条件,没有检查未来期间的预留冲突、设备资格或客户权限,因此不能替代完整的可用性判断。把谓词的适用范围写清楚,才能避免同词误用再次出现。
初始应收来自固定输入,不由归还事件自动计算。这让实验能集中比较状态语义。实际系统中租期、维修费用与账单的传递契约应另行设计,不能凭结算对象恰好存在,就声称完成了跨上下文计费。
方案A在一个模型内明确区分状态
UnifiedOperations 保存已归还、维修已结束、检查已通过三个状态。它提供归还、完成维修和批准检查三个操作。维修要求设备已经归还,检查要求维修已经结束;可租由已归还与检查通过共同决定。
归还操作之后,租赁归还条件成立,但维修完成、设备可租、结算完成都为假。完成维修只改变维修状态,设备仍不可租。直到批准检查,可租才变成真。这样,即使同属一个上下文,也没有用总状态代替不同业务条件。
这个方案适合讨论的前提是:运营人员能够共同维护这些术语,归还与检查规则变化需要紧密协作。实验没有证明这种团队现实已经存在,只展示一种可行模型。若两组人员连“已接收”的含义都无法保持一致,合并后的字段很容易重新含混。
结算对象独立接收付款。第一次收取 50 元后余额是 70,完成仍为假;第二次收取 70 元后余额为零,完成才成立。维修与检查操作没有修改应收,也不能提前消除这笔余额。
方案B通过明确的事实连接
拆分后,RentalContext 只保留已归还和已获放行,不知道维修步骤细节。MaintenanceContext 知道设备是否接收、作业是否完成以及检查是否通过。双方通过带设备编号的事实字典连接。
sequenceDiagram
participant R as 租赁上下文
participant M as 维修上下文
participant S as 结算上下文
R->>M: EquipmentReturned(E1)
Note over R,M: 归还完成;设备尚不可租
M->>M: 完成维修作业
Note over M: 仍需质量检查
M->>R: 检查通过后 EquipmentReleased(E1)
Note over R: 已归还且已放行,可租
S->>S: 收款50:余额70
S->>S: 收款70:余额0,结算完成
第二张图与运行顺序一致。结算两次收款来自外部操作,没有画成维修自动触发付款。所有调用都在一个 Python 进程内,箭头是函数间传值,不是已经实现的消息队列或可靠网络协议。
实验先把 EquipmentReturned(E1) 交给维修,再完成维修与检查,最后把 EquipmentReleased(E1) 交给租赁。只发送 status=COMPLETED 会被租赁入口拒绝,因为无法知道完成的是哪种工作;放行另一个设备 E9 也会被拒绝。两次拒绝后,E1 仍未放行。
这里的翻译刻意很小:维修检查通过,被对方理解为设备放行,而不是复制整个维修对象。字典必须符合当前教学契约,新增字段也会被严格检查拒绝。这有助于暴露契约变化,但不是生产环境的版本兼容方案。
用同一过程比较两种边界
程序对两个方案分别执行归还、维修、检查、部分付款和全额付款,保存每一步的五项结果:归还完成、维修完成、可租、结算完成、未付余额。两个过程得到相同轨迹,并分别核对固定输入中的预期值。
维修完成而检查未通过时,轨迹是 true, true, false, false, 120;检查通过之后,只有可租变成真;首次付款后,只有余额变为 70。相同结果证明两个候选在这些场景下都保留了业务区别,不能证明它们在所有需求下等价。
反例另外构造一个共享状态 COMPLETED,用它同时判断可租与结算完成。设备刚归还时,错误候选把二者都算成真,而两种合法方案仍返回假。这是执行了错误决策后的对照,不是扫描类名是否包含 Context。
此外,提前检查被两种方案分别拒绝;全额付款后再收一元也被拒绝,余额保持零。这些负例检查操作的前置条件和拒绝后的状态,防止边界图掩盖具体规则缺失。
边界选择还需要什么证据
方案 A 少了一组运营与维修之间的数据翻译,但双方必须一起维护模型。方案 B 允许维修独立表达内部进度,代价是租赁只能依据已经传入的放行事实作决定。若事实尚未送达,维修已经通过检查也不会自动改变租赁状态。
因此拆分不能只看名词数量。设备、维修单和账款并不各自天然对应一个服务;需要考虑语言变化、责任主体、协作频率和必须同时成立的规则。当前实验只证明语义能否保留,没有测量团队效率、部署成本或分布式一致性。
子域的重要性也取决于具体企业的业务目标。设备调度可能构成某家租赁公司的核心能力,另一个企业则可能依靠维修工艺形成差异。这里没有把三类子域固定归为核心、支撑或通用,更没有用代码行数作分类依据。
一个实用的评审办法是提出变化,再观察它影响哪个模型。例如维修增加复检步骤时,方案 A 要修改共同维护的运营模型;方案 B 可以先修改维修内部过程,只要放行事实仍保持相同含义,租赁的判断就不需要理解复检细节。若放行的含义也改变,则接口不变仍然可能造成错误。
相反,如果新增规则要求归还与质检在同一操作中立即确认,方案 B 就需要重新讨论协调方式。不能用当前同步函数调用的成功,替未来跨进程执行保证原子性。边界评审应记录这类具体变化压力,而不是给拆分数量打分。
复跑边界对照
1 | |
从仓库根目录运行,需要 Python 3.10 及以上和 Bash,无第三方依赖。实际得到 17 条场景记录,全部 pass=true,退出码为 0。输入、两种对象模型和原始输出分别位于 models/22/、labs/22/、evidence/22/。
可先对照 第06篇:状态流程和决策 中的状态含义,再检查这两种边界。归还、检查、付款分别有自己的条件,跨边界传递的事实必须足以让接收方作出它负责的判断。






