record 类型不是 ddd 值对象的自动实现,需手动满足相等性由所有字段决定、无身份、不可变三要素;其默认仅支持部分不可变和结构相等,字段含可变引用、边界值处理不当、继承及 with 浅拷贝等问题均会破坏值对象契约。

Record 类型本身不是值对象(Value Object)的自动实现,它只是提供了不可变语法糖和值语义基础;真正在 DDD 中充当值对象,必须手动满足「相等性由所有字段决定 + 无身份 + 不可变」三要素——而 record 默认只做了第一项和部分第三项。
record 声明后为什么还不能直接当值对象用
因为 DDD 值对象要求“结构相等即逻辑相等”,而 record 虽然默认实现 Equals 和 GetHashCode,但以下情况仍会破坏契约:
- 字段含可变引用类型(如
List<string></string>),外部调用.Add()会悄悄改变两个逻辑上“相等”的实例状态 - 未显式处理
null或空集合等边界值,导致new Person("") == new Person(null)返回false(但业务上应视为等价) - 继承自
record的子类若添加新字段,父类实例与子类实例永远不等——这违反值对象“类型无关相等”的常见需求(比如Money和它的子变体) -
with表达式是浅拷贝,若字段是 class 类型(非 record),修改嵌套对象会影响所有共享该引用的 record 实例
怎样让 record 真正符合 DDD 值对象语义
关键不是加 record 关键字,而是堵住所有可变入口、统一相等逻辑、明确构造约束:
- 所有字段必须用不可变类型:优先选
ImmutableArray<t></t>、IReadOnlyList<t></t>、ReadOnlyMemory<t></t>;避免List<t></t>、Dictionary<k></k>、T[] - 构造函数中做防御性拷贝:若参数是
IEnumerable<t></t>,立刻转成.ToImmutableArray();若传入null,按业务规则标准化(如转为空集合) - 重写
Equals和GetHashCode仅当需要放宽比较逻辑(例如忽略大小写、忽略空白),否则保持编译器生成版本即可 - 禁止公开任何可变方法或属性 setter;哪怕 record 允许在方法里写
this.Amount = 0,也绝不要这么干——那已不是值对象,是带毒的 class - 若需支持多态(如
Currency下有USD、EUR),改用 sealed record + 工厂方法,别用继承;继承 record 会让Equals自动包含基类字段,但 DDD 值对象通常不依赖继承来表达差异
常见踩坑:with 表达式 vs 值对象语义冲突
当你写 var m2 = m1 with { Amount = m1.Amount + 1 },看似安全,但若 Amount 是 decimal 没问题;一旦字段是 Address 这样的 class 类型,m2.Address 和 m1.Address 仍是同一引用——后续对 m1.Address.Street 的修改会污染 m2。
- 正确做法:把
Address也定义为record,这样with会递归调用其Clone逻辑 - 错误写法:
public record Money(decimal Amount, Address Location)中Address是普通 class - 折中方案:若无法改
Address为 record,就在Money构造时做深拷贝:this.Location = new Address(src.Location.Street, src.Location.City) - 性能提示:深拷贝有开销,高频场景建议用
record struct(C# 10+)替代引用类型 record,避免堆分配
最常被忽略的一点:DDD 值对象的“不可变”是端到端的——从构造入参、字段存储、到对外暴露的每一个引用,都得是只读的。record 只锁住了字段赋值这一道门,其余门你得自己焊死。











