关于 ddd-value-object
用于建模领域驱动设计值对象的技能:这类概念由属性而非身份定义,且不可变。它指导验证、领域操作,以及何时适合封装基本类型。
主要功能
- 01建模不可变且按值相等的概念
- 02在构造时验证取值
- 03将领域操作放在值对象中
- 04区分底层类型相同的概念
适用场景
- 01建模 Money、Address 或 DateRange
- 02用领域不变量替代基本类型
- 03区分底层类型相同的标识符
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ yonatankarp/ software-design-skills --skill ddd-value-object安装后在 Claude Code 中输入/ddd-value-object确认已加载。
详细介绍
一句话概述
值对象是完全由其属性定义的概念——没有身份、不可变,并按值相等。
何时使用此技能
- 为
Money、Address、DateRange、EmailAddress、ProductCode、Percentage、PhoneNumber建模。 - 替换承载了类型系统无法强制执行的不变量的基本类型(
String、Long、BigDecimal)。 - 区分共享同一种底层基本类型的两个概念(
CustomerId与OrderId——二者都是 UUID,但绝不能互换)。 - 封装属于某个概念的领域操作:
Money.add(Money)、Address.normalize()、DateRange.contains(Instant)。
何时不使用此技能
- 该概念具有生命周期或身份 → 使用
ddd-entity。 - 封装既不增加不变量,也不增加行为——为了封装而封装只是徒增形式。
- 某个基本类型只在一处使用,且从不跨越边界——封装的成本可能超过收益。
核心内容
值对象是不可变的:构造完成后,其字段永不改变。对它们执行操作会返回新的值对象,而不是修改接收者。它们按值相等:具有相同属性值的两个值对象可以互换使用。
值对象也是领域操作的天然归属地。Money 应有一个 add 方法,并拒绝将 USD 与 EUR 相加。Address 可以有一个返回规范形式的 normalize 方法。DateRange 应能判断某个 Instant 是否在其范围内。将这些操作放在值对象上,而不是放在 MoneyUtils 这类静态辅助工具中,可以让领域拥有更丰富的表达方式。
应在构造时进行验证。EmailAddress("notanemail") 应在构造时失败,而不是等到之后有人尝试使用它时才失败。这就是“让无效状态无法表示”的含义。
决策启发
- 当基本类型 (a) 具有语言无法表达的不变量,(b) 具有特定于领域的操作,或 (c) 被两个不应互换的领域概念共用时,应将其封装起来。
- 即使在允许使用可变对象的语言中,也优先选择不可变值对象。
- 如果你正准备编写
MoneyUtils.add(money1, money2),请停下来——这个操作应属于Money。
反模式
- 可变值对象——setter 会破坏相等语义,并引发共享状态错误。
- 带有身份字段的值对象——这其实是伪装成值对象的实体。
- 认为每个基本类型都需要封装——这是过度设计。只有在基本类型承载领域含义时才封装,不要条件反射般地一概封装。
- 验证失败时抛出没有消息的通用
IllegalArgumentException——应定义清晰的领域异常,或使用结果类型。
另请参阅
ddd-entity— 相反的情况;具有身份的概念。ddd-anti-patterns— 基本类型痴迷、可变值对象。ddd-kotlin-idioms— 如何在 Kotlin 中惯用地表达值对象(data class、value class)。
参考资料
- Evans,《领域驱动设计》(2003),第 5 章(以软件表达模型)。
评论
支持 Markdown