知乎上有人提出一个问题:
领域对象讲究充血模型在理论上讲非常合理。
可是在实践过程中就会陷入"业务逻辑到底应该写在领域对象还是领域服务"的怪圈;同时看代码的人也无法知道业务逻辑到底会散落在什么地方;最可怕的是原本简单的可以放在领域对象的逻辑因为业务需求变复杂之后很可能已经超出领域对象能处理的范畴,需要转移到领域服务 与其这样纠结,为何不干脆把职责分离了,领域服务承载所有业务逻辑,领域对象作为贫血模型承载数据结构的职责呢?
领域对象 + 领域服务 = 数据 + 行为 而不是强求领域对象既包含数据又包含行为
这是一个非常好的问题,它触及了DDD实践中最核心的困惑点。这个观察非常准确:理论上充血模型很合理,但实践中却容易陷入"业务逻辑到底放在哪"的泥潭,导致代码散乱、难以维护。
我的答案是:领域对象必须"充血",但我们需要重新理解"充血"的真正含义。它不应是传统意义上将全部行为塞进实体内的"行为充血",而应是构建一个富含领域语义、可自由导航的"结构充血"。
一、问题的根源:混淆了"结构"与"行为"
传统DDD实践中的一个常见误区,是试图让聚合根成为全能的"行为上帝"。例如,将一个复杂的placeOrder(下单)流程的所有逻辑------检查库存、验证优惠券、计算价格、创建订单------全部封装进Order对象的placeOrder()方法中。
这会导致你提到的所有问题:
- 聚合根臃肿 :
Order对象变成一个巨大的"上帝类",难以理解和测试。 - 依赖混乱:为了执行逻辑,聚合根被迫注入各种Repository和Service,破坏了领域模型的纯洁性。
- 演化困难:当业务流程变得复杂或需要跨服务协调时,你会发现这个庞大的方法"已经超出领域对象能处理的范畴"。
其根本原因,是我们混淆了领域模型中的两个正交关注点:
- 结构(是什么):系统的稳定状态,由实体、值对象及其关联关系构成。它回答了"领域中有哪些概念,它们如何关联"。
- 行为(怎么做):系统状态的变化过程,即业务流程。它回答了"为了完成一个业务目标,需要经历哪些步骤"。
二、范式转变:从"行为充血"到"结构充血"
一种更先进的实践是进行关注点分离:
- 让聚合根回归"结构"本质 ,充当领域语言的载体 和信息的访问中心。
- 让复杂的"行为"上浮 ,由流程编排引擎(如
NopTaskFlow)或领域服务来协调。
1. 聚合根作为"领域语言"的载体
这才是"充血模型"的真正价值所在。想象一下,你的领域模型本身构成了一种"语言":
- 类名、属性名、方法名 就是你的领域词汇表 (如
Order、Customer、totalAmount、isVIP())。 - 聚合根通过其关联关系和方法签名 ,定义了这些词汇如何组合成合法"句子"的文法。
例如,order.customer.creditLimit 或 order.canBeCancelled() 就是一个符合文法的、有业务意义的领域表达式。
在这种范式下,编程变成了"只针对聚合根编程" 。你的业务逻辑代码中,只出现这些纯粹的领域表达式,而完全看不到 orderRepository.findById(...) 或 customerService.getCreditLimit(...) 这种技术性代码。聚合根为你构建了一个稳定的、与技术无关的"信息宇宙",你只需在其中自由导航。
2. 关键洞察:聚合根≠DTO,必须是声明式的
这里有一个至关重要的区别:聚合根不能是简单的DTO 。DTO是数据的被动载体,而聚合根是主动的信息空间。
- 一次性表达所有概念 :聚合根的设计必须完整表达领域中的所有相关概念和关联关系。
Order应该有getCustomer()、getItems()、getShippingAddress()等方法,无论调用方是否需要这些信息。 - 声明式而非立即执行 :关键就在于,这些方法的调用必须是声明式的,而不是在获取聚合根时就立即加载所有数据。 当代码访问
order.getCustomer().getName()时,系统才真正去加载客户信息。这就是"延迟加载"的精髓。
形式上的聚合 ≠ 数据存取时机的聚合。我们在设计时按照领域边界进行逻辑聚合,但在运行时按需加载数据。这完美解决了性能顾虑------你不会因为设计了一个丰富的领域模型就不得不加载所有数据。
3. GraphQL与聚合根的完美对偶
这正是GraphQL与聚合根天生契合的原因。GraphQL的 @selection 机制与声明式聚合根形成了完美的对偶关系:
- 聚合根定义了"有哪些信息可用"(供给能力)
- GraphQL的selection定义了"实际需要哪些信息"(消费需求)
正如NopGraphQL引擎的做法,我们完全可以在REST接口中引入 @selection 参数,获得同样的能力:
bash
# 传统REST - 返回所有字段
GET /orders/123
# 增强REST - 按需返回
GET /orders/123?@selection=id,status,customer{name,email}
这种设计让聚合根的丰富领域表达能力与接口的精确数据需求达成了完美平衡。你既可以设计出完整表达业务概念的领域模型,又不用担心性能问题。
4. 业务流程作为"结构空间"上的动力学
那么,复杂的业务逻辑(行为)去哪了?它们被提取到流程编排引擎 (如NopTaskFlow)中。
以"订单打折"为例,在传统充血模型中,你可能会在Order中写一个庞大的calculateDiscount方法,里面充满if-else。而在新范式下:
yaml
# 在流程引擎中定义打折规则(行为)
steps:
- type: xpl
name: book_discount_1
when: "orderBo.originalPrice < 100" # 这里访问聚合根的信息
source: |
orderBo.order.setRealPrice(orderBo.originalPrice);
- type: xpl
name: book_discount_4
when: "orderBo.originalPrice >= 300"
source: |
orderBo.order.setRealPrice(orderBo.getOriginalPrice() - 100);
在这个例子中:
- 聚合根(
Order) 提供了getOriginalPrice()和setRealPrice()方法。它充的是"结构"和"基础行为"的血,负责表达"订单有原价和实付价"这个领域事实。 - 流程引擎 则充任"协调者",它读取聚合根的状态,并根据业务规则决定如何修改它。复杂的、易变的打折规则被外置和可视化。
三、这种范式如何解决你的困惑?
-
终结"业务逻辑归属"的怪圈 :归属原则变得清晰无比。内在的、稳定的核心数据和计算属性和方法放在聚合根内;跨实体的、多步骤的、易变的业务流程放在流程引擎中。 看代码的人一目了然:领域对象定义了业务概念,流程模型定义了业务规则。
-
解决性能顾虑 :通过声明式聚合根 + GraphQL按需加载的组合,你既可以设计丰富的领域模型,又无需担心不必要的数据加载。形式上的聚合不意味着存取时机的聚合。
-
应对复杂演化 :当"下单"流程需要增加风控检查时,你无需修改
Order聚合根,只需在PlaceOrderTaskFlow中插入一个新的步骤。系统的复杂性与核心领域模型的复杂性解耦,演化能力得到质的提升。 -
代码不再散落:业务流程被集中定义在流程模型文件中,而不是散落在多个服务或聚合根的方法里。它从"隐式"的代码执行顺序,变成了"显式"的、可视化的流程图纸。
结论:要充血,但要充对"血"
所以,回到你的问题:与其退回到贫血模型,放弃面向对象的所有好处,不如进行一次范式升级。
- 拒绝贫血模型:那是对复杂性的投降,会导致业务逻辑失去内聚,最终退化为难以维护的"事务脚本"。
- 避免传统的"行为充血":那会让你陷入聚合根臃肿和技术耦合的困境。
- 拥抱"结构充血" :让领域对象充"领域语义"和"信息结构"的血,成为领域语言的载体;同时,通过声明式设计和GraphQL的按需加载机制解决性能问题。
这种"声明式聚合根(结构空间) + 流程引擎(行为动力学) + GraphQL(按需消费)"的架构,既保留了面向对象封装和领域表达力的优势,又获得了流程化编排的灵活性、可视性和可维护性,同时还彻底解决了性能顾虑。它才是DDD在现代复杂业务系统中最有生命力的实践方式。
参考资料
- DDD本质论之理论篇: 结合(广义)可逆计算理论,从哲学、数学到工程层面,系统性地剖析了DDD(领域驱动设计)的技术内核,认为其有效性背后存在着数学必然性。
- DDD本质论之实践篇:作为理论篇的续篇,重点介绍了Nop平台如何将可逆计算理论应用于DDD的工程实践,将DDD的战略与战术设计有效地落实到代码和架构中,从而降低实践门槛。
基于可逆计算理论设计的低代码平台NopPlatform已开源:
- gitee: gitee.com/canonical-e...
- github: github.com/entropy-clo...