Day32-08|13.3.3 业务逻辑层实体设计:用领域对象保护业务状态与不变量
Day32-08|13.3.3 业务逻辑层实体设计:用领域对象保护业务状态与不变量
对应教材:《系统架构设计师教程(第2版)》第13章“层次式架构设计理论与实践”13.3“中间层架构设计”中的 13.3.3“业务逻辑层实体设计”。本文面向软考高级系统架构设计师,重点学习实体、值对象、聚合、领域服务、状态和持久化之间的关系,并分析贫血模型、充血模型与企业应用实践。
一、教材定位与学习目标
业务逻辑层不能只由一组无状态的 CRUD 方法组成。现实业务对象通常具有身份、属性、生命周期和行为:订单会创建、支付、发货和取消,账户会开户、冻结和解冻,保单会生效、变更和终止,设备会注册、上线、离线和维护。如果对象的状态可以被任意修改,业务规则就会散落在控制器、服务、数据库触发器和批处理脚本中,系统很难保证一致性。
实体设计的核心,是把业务中具有持续身份和生命周期的对象建模为能够保护自身不变量的领域对象,并通过聚合和服务组织对象之间的协作。实体设计并不意味着所有数据库表都必须对应一个实体,也不意味着领域对象不能使用 ORM。架构师需要根据业务规则、变化原因、一致性边界和生命周期选择合适的模型。
本篇学习目标:
- 理解实体、值对象、聚合根、领域服务和数据传输对象的区别。
- 掌握实体身份、状态、行为和不变量的设计方法。
- 分析贫血模型、充血模型和事务脚本的适用条件。
- 处理实体与数据库映射、并发、版本、序列化和生命周期问题。
- 能够结合案例题和论文说明业务逻辑层实体设计的架构取舍。
二、实体的基本概念
1. 实体的身份
实体的主要特征是具有持续身份。两个实体即使属性完全相同,只要身份不同,仍然是两个不同对象。例如两个客户可能姓名和地址相同,但客户编号不同;两笔订单金额相同,也不代表可以互换。身份可以由业务编号、全局唯一标识或复合标识构成,必须明确生成、持久化和生命周期规则。
2. 实体的状态
实体状态由属性和关联对象共同组成。状态变化应通过有业务含义的操作完成,例如订单通过“支付”进入已支付,而不是让外部代码直接把 status 字段改成某个值。状态变化前应检查前置条件,变化后应保持不变量。
3. 实体的行为
行为是实体对业务操作的响应。实体行为应使用业务语言命名,表达意图而不是数据库动作。例如“冻结账户”“添加订单行”“确认收货”比“更新字段”更能保护业务规则。行为可以修改自身状态,必要时发布领域事件,但不应直接控制页面和外部协议。
4. 实体的生命周期
生命周期包括创建、修改、暂存、激活、冻结、完成、取消和归档等阶段。不同状态允许的行为不同,非法状态转换必须被拒绝。历史数据是否可修改、删除还是只能作废,也要由业务规则明确。
三、实体、值对象与数据传输对象
| 类型 | 是否有独立身份 | 是否强调可变 | 主要用途 |
|---|---|---|---|
| 实体 | 有 | 可变但受规则约束 | 表示持续存在的业务对象 |
| 值对象 | 无,按值相等 | 通常不可变 | 表示金额、地址、时间段等概念 |
| 聚合根 | 有,是聚合入口 | 保护聚合一致性 | 管理一组关联对象 |
| DTO | 无业务身份要求 | 用于传输 | 跨层或跨进程传递数据 |
| 查询模型 | 面向读取 | 可按查询优化 | 页面、报表和搜索展示 |
1. 值对象
金额、币种、地址、电话号码、时间范围和坐标等概念通常适合作为值对象。值对象通过属性值判断相等,最好不可变,以避免共享引用被意外修改。例如金额应同时包含数值和币种,不能只使用浮点数;时间范围应保证开始时间不晚于结束时间。
2. DTO
DTO 用于表现层、服务间或消息之间传输数据,不应自动拥有完整业务行为。将 DTO 直接当实体使用,会把外部输入与内部状态混在一起,使攻击者可以修改不应修改的字段。应用服务应将命令 DTO 转换为受控的业务操作。
3. 查询模型
报表和列表经常需要组合多个实体的数据,直接返回实体会引入不必要的加载、序列化和敏感字段暴露。查询模型可以针对读取场景优化,但必须经过权限和数据范围过滤,不能因为是只读就绕过治理。
四、聚合与一致性边界
1. 聚合的含义
聚合是一组需要在业务上保持一致的实体和值对象集合。聚合根是外部访问聚合的唯一入口,负责保护内部不变量。外部对象不应直接持有聚合内部实体并修改其状态,而应通过聚合根执行有意义的行为。
2. 聚合边界的划分
聚合边界不等同于数据库表边界或对象引用图边界。划分时应考虑哪些规则必须原子满足、哪些对象一起变化、并发冲突范围、生命周期和业务责任。例如订单和订单行通常可以属于一个聚合,库存预留可能属于库存聚合,不一定与订单放在同一事务中。
3. 聚合之间的协作
聚合之间尽量通过标识、服务和领域事件协作,而不是直接持有对方内部对象。跨聚合操作可以由应用服务编排,或由领域服务协调;如果不要求立即强一致,可以采用事件和最终一致性。聚合之间的直接循环引用会增加加载、序列化和事务复杂度。
4. 聚合过大与过小
聚合过大导致锁竞争、事务时间长和并发冲突;过小则使一个业务规则跨多个事务,补偿和一致性复杂。应根据运行数据和业务不变量调整,而不是追求理论上的固定大小。
五、实体行为与不变量设计
1. 不变量
不变量是业务对象在任何合法状态下都必须满足的条件。例如订单总金额等于订单行金额汇总,已取消订单不能支付,账户余额不能低于透支限额,审批通过后不能回到草稿。实体或聚合应在行为执行前后维护这些约束。
2. 有意图的操作
对外暴露“支付订单”“取消订单”“增加商品行”等操作,可以在内部集中判断状态、权限前置条件和副作用;暴露通用 setter 则会让任何调用方随意修改状态,导致规则失效。即使使用 ORM,也可以限制属性写入并通过方法改变状态。
3. 业务异常
非法状态、额度不足、重复操作和版本冲突属于可预期的业务异常,应使用稳定的业务错误表达。数据库异常、网络超时和程序错误属于技术异常,需要由应用服务和基础设施处理。实体不应捕获所有异常并静默处理。
4. 领域事件
实体或聚合发生重要业务事实时,可以产生领域事件,例如订单已支付、账户已冻结和申请已提交。事件描述已经发生的事实,不应作为随意调用内部方法的命令。事件发布需要考虑事务一致性、重复投递、版本和敏感信息。
六、贫血模型、充血模型和事务脚本
1. 贫血模型
贫血模型的对象主要保存数据,业务逻辑集中在服务类。它实现简单,适合业务规则较少、数据处理以 CRUD 为主的系统,也容易与传统 ORM 和分层架构结合。缺点是规则可能分散在多个服务,多个入口重复实现,实体无法保护自身不变量。
2. 充血模型
充血模型让实体和领域对象拥有与自身数据密切相关的行为和规则,服务层主要负责用例编排。它适合业务复杂、状态多、规则稳定且需要长期维护的领域。缺点是建模难度较高,团队需要掌握领域语言,持久化和外部系统边界也更复杂。
3. 事务脚本
事务脚本以一个过程完成一次业务事务,直接查询和更新数据。对于简单、短生命周期、规则少的功能,事务脚本可以快速交付;对于复杂领域,如果所有逻辑都写成脚本,容易产生重复和难以测试的条件分支。
4. 选择原则
不是所有系统都必须使用充血模型。选择时要看业务复杂度、变化频率、团队能力、系统寿命和一致性要求。复杂核心域可以采用充血模型,简单支撑域采用事务脚本或贫血模型,关键是边界清晰、规则可追踪、质量可验证。
七、实体与持久化设计
1. 领域模型与数据库模型分离
数据库模型为存储、查询和性能服务,领域模型为业务行为和不变量服务。两者可以相似,但不必完全相同。直接把数据库实体暴露给业务和接口,会让表结构变化扩散到各层;直接把所有领域对象一一映射到表,也可能产生复杂关联和性能问题。
2. 映射策略
可以通过 ORM、数据映射器、仓储或手工转换实现领域模型和持久化模型之间的映射。映射要处理身份、值对象、继承、集合、懒加载、级联、并发版本和软删除。核心业务对象的加载边界应明确,避免隐式懒加载造成远程调用或大量查询。
3. 仓储与查询
仓储面向聚合提供保存和按业务条件加载的抽象,不能成为任意 SQL 的垃圾桶。复杂报表和搜索可以使用专用查询服务或读模型。写模型和读模型分离后,必须设计数据同步、时效、权限和重建策略。
4. 并发控制
实体更新常使用乐观锁版本号,提交时检查版本是否仍然匹配;高冲突资源可以使用悲观锁、队列或分段锁。冲突应返回可理解的业务提示,不应静默覆盖他人修改。版本号同时可以用于事件顺序和缓存失效。
八、案例分析:电商订单领域实体设计
某电商系统把订单、订单行和支付状态全部作为可任意修改的数据库对象。控制器直接接收客户端 JSON 并更新字段,导致已发货订单可以被修改价格,重复支付会创建多条支付记录,订单总金额也经常与订单行不一致。
1. 实体和聚合划分
订单是聚合根,订单行、收货地址快照和优惠结果属于订单聚合内部;支付记录和库存预留分别属于支付和库存边界,通过业务编号关联。订单暴露添加商品、确认金额、提交、取消和确认收货等意图操作,不提供任意状态 setter。
2. 不变量
订单行数量必须为正,订单总金额由订单行和优惠规则计算,已提交订单不能随意修改商品,已支付订单不能取消而不经过退款流程,同一订单不能被同一支付请求重复处理。实体行为和应用服务共同保证这些约束。
3. 跨聚合协作
下单在订单本地事务中生成订单和待支付状态,发布订单已创建事件;库存服务预留库存并反馈结果;支付服务使用订单号和支付幂等键处理支付;支付成功后发布订单已支付事件。库存、支付和订单通过状态机、事件和补偿保持最终一致,不把所有外部调用放进一个长事务。
4. 持久化和接口
客户端 DTO 映射为创建订单命令,应用服务加载商品和价格的权威信息,不信任客户端传入的总金额;订单聚合通过仓储保存,列表页面使用专用查询模型;数据库使用版本号防止并发修改;敏感支付信息不进入订单日志和普通查询模型。
5. 效果指标
通过订单金额不一致数、重复支付数、并发冲突覆盖率、订单规则重复率、接口越权缺陷、订单查询响应时间和异常补偿完成率评价改造效果。
九、综合知识练习与详细答案
练习一:单项选择
下列最能体现实体封装业务行为的是( )。
A. 任何代码都可以直接修改 status 字段 B. 订单通过 pay() 方法完成支付状态转换并检查前置条件 C. 页面根据按钮决定订单是否可支付 D. 数据库触发器承担所有业务规则
答案:B。实体通过有意图的行为保护状态和不变量,页面不能成为唯一规则执行点。
练习二:多项选择
聚合设计通常应考虑( )。
A. 一致性边界 B. 并发冲突范围 C. 生命周期 D. 业务责任 E. 只按数据库表划分
答案:A、B、C、D。数据库表不是划分聚合的唯一依据。
练习三:案例题
某系统所有实体均只有属性和 getter/setter,业务规则集中在多个 Service 中。不同入口实现了不同的订单取消条件,数据库和接口层也可以直接修改订单状态。请分析问题并给出改进方案。
参考答案:系统属于贫血模型失控,规则分散、重复且无法由实体保护;公共 setter 使状态可被任意修改,数据库和接口绕过业务服务后产生非法状态。改进时应先识别订单生命周期和关键不变量,建立订单聚合根及有意图的取消、支付、发货等行为;将与订单状态和订单内容密切相关的规则收拢到聚合,将跨对象决策放入领域服务;应用服务负责用例编排、权限、事务和外部协作;禁止接口直接绑定持久化实体,使用命令 DTO 和查询模型;通过仓储和映射隔离数据库;采用版本号处理并发,使用领域事件和补偿处理跨聚合一致性;为实体行为、应用用例和接口契约分别建立测试,并迁移旧数据和清理非法状态。
十、案例题答题模板
遇到实体、领域对象、聚合和业务规则类题目,可以按以下结构作答:
- 说明现状:数据对象可任意修改、规则分散、状态不一致。
- 识别实体身份、生命周期、状态和关键不变量。
- 区分实体、值对象、聚合根、领域服务、DTO和查询模型。
- 根据一致性、并发和责任划分聚合边界。
- 使用有意图的行为和状态机保护实体,不直接暴露 setter。
- 通过仓储、映射和查询模型隔离持久化与展示,处理版本和并发。
- 对跨聚合协作采用事件、最终一致、补偿和对账。
- 用业务错误、测试和运行指标验证实体设计效果。
十一、论文写作素材
开头素材
“原有订单系统将数据库记录直接暴露给控制器和接口,实体只有属性和通用修改方法,导致业务规则分散、订单状态可被越权修改和跨系统一致性难以保证。为提高核心领域的正确性和可维护性,我参与进行业务逻辑层实体设计,围绕实体身份、生命周期、不变量和一致性边界建立聚合模型。”
正文素材
可以重点写四点:第一,如何从业务语言识别实体和值对象,并定义聚合根和生命周期;第二,如何采用有意图的行为、状态机和领域服务保护不变量;第三,如何通过仓储、映射、查询模型和 DTO 隔离领域模型与数据库、接口;第四,如何使用版本号、事件、补偿和对账处理并发及跨聚合一致性。应说明贫血模型和充血模型的权衡,不要把某一种模型写成所有场景的唯一答案。
结尾素材
“实践表明,实体设计的价值不在于增加对象数量,而在于让业务身份、状态和不变量有明确的承载位置。通过合理的聚合边界和受控行为,系统能够减少规则重复和非法状态;通过持久化隔离、并发控制和事件补偿,又可以在保持领域清晰的同时满足企业应用的性能和集成要求。”
十二、今日复盘
本节核心结论是:实体表达有身份和生命周期的业务对象,值对象表达按值相等的概念,聚合根保护一致性边界,应用服务负责用例编排;业务状态必须通过受控行为变化,而不能被任意字段修改。
请回答:
- 实体和值对象有什么区别?
- 聚合根为什么是外部访问聚合的主要入口?
- 贫血模型、充血模型和事务脚本分别适合什么场景?
- 为什么 DTO 不应直接作为实体使用?
- 如何处理跨聚合的最终一致性和并发冲突?
- 领域模型与数据库模型为什么不必完全相同?
请以订单、账户、理赔或审批系统为例,画出“实体—值对象—聚合根—应用服务—仓储—领域事件”的关系,标出不变量、事务边界、版本号、命令 DTO 和查询模型。完成后,你就掌握了 13.3.3 在综合知识、案例分析和论文写作中的核心内容。