聚合不变量检查位置的最优方案咨询——以Question聚合场景为例
聚合不变量:放在聚合根还是值对象?
这是个特别棒的问题,刚好戳中了DDD里聚合与值对象职责划分的核心!我来结合DDD的设计原则帮你理清楚哪种方案更优。
首先,咱们先明确两个核心概念的职责:
- 值对象:它的本质是封装特定领域概念的属性及该属性必须遵守的所有不变规则——简单说,值对象要为自己的“有效性”负责。
- 聚合根:它的职责是维护聚合级别的不变量(也就是多个实体/值对象之间的关联规则,比如一个问题最多关联5个选项),而不是单个值对象的内部规则。
先看你当前的实现问题
你现在把QuestionText的长度检查放在了Question聚合的构造函数里,虽然能直接看到规则,但有几个明显的弊端:
- 违反单一职责:聚合根不该操心单个值对象的内部校验,它的精力应该放在跨实体/值对象的规则上。
- 冗余风险:如果后续另一个聚合(比如
Survey)也需要用到QuestionText,你得重复写一遍相同的长度检查逻辑,很容易出现不一致。 - 维护成本高:如果以后问题文本的长度规则变了(比如改成80-600字符),你得找到所有用到这个检查的聚合去修改,而不是只改一处。
更优的方案:把不变量移到值对象中
把QuestionText的校验逻辑完全封装到它自己的类里才是符合DDD设计的做法,理由如下:
- 值对象的本职工作:
QuestionText作为代表“问题文本”这个领域概念的值对象,本身就应该确保自己的内容是合法的——就像Email值对象要校验格式、Money值对象要确保金额非负一样,这是它的核心职责。 - 复用性与一致性:无论哪个聚合需要使用合法的问题文本,直接创建
QuestionText实例即可,所有校验逻辑都在一处,保证规则统一。 - 聚合根更简洁:聚合根的构造函数只需要负责创建值对象实例,以及处理真正属于聚合级别的规则(比如如果你的Question必须关联一个创建者ID,那这个规则就放在聚合根里检查)。
- 符合开闭原则:以后修改问题文本的规则时,只需要改动
QuestionText类,完全不需要碰Question聚合,降低了修改带来的风险。
改进后的代码示例
先写QuestionText值对象:
public class QuestionText { private final String value; public QuestionText(String text) { // 在这里封装所有属于问题文本的不变量校验 if (text == null || text.isBlank()) { throw new IllegalArgumentException("问题文本不能为空"); } if (text.length() < 100 || text.length() > 500) { throw new IllegalArgumentException("问题文本长度必须在100-500字符之间"); } this.value = text; } // 提供获取值的方法(值对象通常是不可变的,所以只暴露读方法) public String getValue() { return value; } }
然后是简化后的Question聚合:
public class Question { private final QuestionId id; private final QuestionText text; public Question(String id, String text) { this.id = new QuestionId(id); // 直接创建QuestionText,校验逻辑已经在值对象内部处理 this.text = new QuestionText(text); // 这里只处理聚合级别的不变量,比如: // if (someCrossEntityRuleIsBroken()) { // throw new IllegalStateException("聚合级规则被违反"); // } } }
关于“聚合中看不到不变量”的顾虑
你担心聚合里看不到规则,但这其实是封装带来的好处——领域规则被妥善地放在了它应该待的地方,而不是散落在各个聚合里。如果需要确认规则,直接看QuestionText的实现即可,这比在聚合里写一堆校验逻辑更清晰。而且创建值对象时如果不符合规则会直接抛出异常,能确保只有合法的QuestionText才能进入聚合,完全不用担心无效数据流入。
总结一下:单个值对象的内部不变量,就交给值对象自己维护;聚合根只负责跨实体/值对象的聚合级规则——这才是DDD中职责划分的正确姿势。
内容的提问来源于stack exchange,提问作者ismaelcabanas
相关产品推荐
相关产品推荐

