写在前面 上一篇讲聚合时反复出现两个角色——实体(Entity) 和 值对象(Value Object) 。它们是领域建模的最基础积木,但很多开发者只会用实体,把"值对象"这个被严重低估的工具晾在一边。
这篇把它们讲清:什么时候该用实体、什么时候该用值对象、怎么用 .NET 实现、怎么映射到数据库。掌握值对象,你的模型会干净一大截。
一、核心区别:身份 vs 值 1
2
3
4
5
6
7
8
9
10
11
实体(Entity):靠"身份"区分
有唯一 ID,属性变了还是它
判等:ID 相同就是同一个
例:订单 Order(有 OrderId)、用户 Customer
问:"这是哪一个?" → 实体
值对象(Value Object):靠"值"区分
没有 ID,所有属性相同就相等
不可变(改 = 换新对象)
例:地址 Address、金额 Money、日期范围 DateRange
问:"这是多少/什么样的?" → 值对象
1
2
3
4
5
6
7
8
9
10
一个判断技巧:
两个"完全一样"的东西,是两个还是同一个?
两张一模一样的 100 元 → 是同一个"金额"(值对象 Money)
(你不在乎是哪张钞票,只在乎面值)
两个同名同姓的用户 → 是两个不同的用户(实体 Customer)
(你在乎"是哪一位")
在乎"是哪一个" → 实体
不在乎、只在乎"值是什么" → 值对象
二、优先用值对象 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
经验法则:能用值对象,就别用实体。
值对象的好处:
✓ 不可变 → 天然线程安全、无副作用
✓ 按值判等 → 测试和比较简单
✓ 无身份管理 → 不用操心 ID 生成、生命周期
✓ 自带校验 → 构造时即合法(金额不能负、邮箱要合规)
✓ 组合性强 → 小值对象拼出大概念
实体是"有状态、有生命周期"的东西,是少数。
现实中大多数概念其实是值对象,只是被误建成了实体。
典型误建:
Address 做成有 AddressId 的实体 → 没必要,地址就是值
Money 做成实体 → 金额就是值
只有一个数量字段的"积分记录" → 可能是值
三、值对象的 .NET 实现 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 推荐:用 record / record struct,自动实现"不可变 + 按值判等"
public readonly record struct Money ( decimal Amount , string Currency )
{
// 构造时校验:保证"创建即合法"
public Money
{
if ( Amount < 0 ) throw new DomainException ( "金额不能为负" );
if ( string . IsNullOrWhiteSpace ( Currency ))
throw new DomainException ( "必须指定币种" );
}
public Money Add ( Money other )
{
if ( Currency != other . Currency )
throw new DomainException ( "币种不同,不能相加" );
return this with { Amount = Amount + other . Amount }; // 返回新对象
}
public Money Multiply ( int times ) => this with { Amount = Amount * times };
}
// 用法:
// var price = new Money(99m, "CNY");
// var total = price.Multiply(3); // 新对象,price 不变
// price == new Money(99m, "CNY") // true(按值判等)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 复杂值对象用 record(class),可包含多字段和行为
public sealed record Address (
string Province , string City , string Detail , string Zip )
{
public string FullText => $"{Province}{City}{Detail}({Zip})" ;
public bool IsDomestic => Zip . StartsWith ( "1" ) || /* … */ ;
}
// 日期范围:典型值对象,不可变 + 自带规则
public sealed record DateRange ( DateTime Start , DateTime End )
{
public DateRange
{
if ( End < Start ) throw new DomainException ( "结束不能早于开始" );
}
public bool Overlaps ( DateRange other ) =>
Start < other . End && other . Start < End ;
public TimeSpan Duration => End - Start ;
}
1
2
3
4
值对象实现三要点:
1. 不可变(record 自动;class 的话所有 setter private/无 setter)
2. 按值判等(record 自动;class 要重写 Equals/GetHashCode)
3. 构造即校验(在构造函数/record 的 init 校验上下文里校验)
四、实体的 .NET 实现 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 实体基类:提供 ID 与按 ID 判等
public abstract class Entity < TId > where TId : notnull
{
public TId Id { get ; protected set ; } = default !;
public override bool Equals ( object? obj ) =>
obj is Entity < TId > other && EqualityComparer < TId >. Default . Equals ( Id , other . Id );
public override int GetHashCode () => Id . GetHashCode ();
}
// 实体:有身份、有状态流转
public class Customer : Entity < CustomerId >
{
public CustomerName Name { get ; private set ; } // 用值对象装字段
public Email Email { get ; private set ; } // 用值对象
public CustomerStatus Status { get ; private set ; }
private readonly List < Address > _addresses = new (); // 值对象集合
public IReadOnlyCollection < Address > Addresses => _addresses . AsReadOnly ();
public Customer ( CustomerId id , CustomerName name , Email email ) : base ()
{
Id = id ;
Name = name ?? throw new DomainException ( "姓名必填" );
Email = email ?? throw new DomainException ( "邮箱必填" );
Status = CustomerStatus . Active ;
}
public void ChangeEmail ( Email newEmail )
{
if ( Status == CustomerStatus . Closed )
throw new DomainException ( "已注销客户不能改邮箱" );
Email = newEmail ;
}
}
1
2
3
4
5
实体要点:
- 继承 Entity<TId>,按 ID 判等(不是按所有字段)
- 状态流转通过方法(ChangeEmail),不暴露 setter
- 字段尽量用值对象包装(CustomerName / Email),而非裸 string
→ 裸 string "abc" 不是合法邮箱;Email 值对象构造即校验
五、强类型 ID(标识类型) 1
2
3
4
5
6
7
8
9
10
11
痛点:
public void Transfer(Guid fromAccountId, Guid toAccountId, decimal amount)
→ fromAccountId 和 toAccountId 都是 Guid,传反了编译器不报错!
强类型 ID:给每个 ID 套一层类型
public readonly record struct AccountId(Guid Value);
public readonly record struct CustomerId(Guid Value);
void Transfer(AccountId from, AccountId to, Money amount)
→ 传反了?类型不匹配,编译直接报错。
→ 顺手解决了"裸 Guid 满天飞、容易传错"的经典 bug。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public readonly record struct AccountId ( Guid Value )
{
public static AccountId New () => new ( Guid . NewGuid ());
public override string ToString () => Value . ToString ();
}
public class Account : Entity < AccountId >, IAggregateRoot
{
public Money Balance { get ; private set ; }
public void Withdraw ( Money amount )
{
if ( amount . Amount > Balance . Amount )
throw new DomainException ( "余额不足" );
Balance = Balance . Add ( amount with { Amount = - amount . Amount });
}
}
// 调用:Transfer(new AccountId(...), new AccountId(...), money)
// —— 两个参数类型都是 AccountId,但位置固定,传错编译报错
六、映射到数据库(EF Core) 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 实体 → 表
modelBuilder . Entity < Order >( b =>
{
b . HasKey ( o => o . Id );
b . Property ( o => o . Status ). HasConversion < string >();
// 聚合内的子实体(订单项):同一个聚合,一起存取
b . OwnsMany ( o => o . Items , item =>
{
item . WithOwner (). HasForeignKey ( "OrderId" );
item . HasKey ( "Id" );
item . Property ( i => i . Price ). HasConversion (
m => m . Amount , v => new Money ( v , "CNY" ));
});
});
// 值对象 → Owned Entity(值对象没有自己的表身份,归属宿主)
modelBuilder . Entity < Customer >( b =>
{
b . OwnsOne ( c => c . Name ); // CustomerName 作为宿主的一列/几列
b . OwnsOne ( c => c . Email );
b . OwnsMany ( c => c . Addresses ); // 值对象集合 → 关联表
});
// 强类型 ID → 转换为底层类型存储
modelBuilder . Entity < Account >( b =>
{
b . Property ( a => a . Id ). HasConversion (
id => id . Value , v => new AccountId ( v ));
});
1
2
3
4
5
6
7
EF Core 映射心智模型:
实体(聚合根、子实体)→ 有主键 → 表/行
值对象 → 无独立身份 → OwnsOne(单值)/ OwnsMany(集合)→ 宿主的列或子表
关键:值对象不该有自己的 DbSet(它不独立),
子实体的仓储也归聚合根(OrderItem 不单独存取)。
这与第二篇"按聚合根整体存取"一致。
七、一张速查表 1
2
3
4
5
6
7
8
9
场景 用实体还是值对象
──────────────────────────────────────────────────────
订单、用户、账户(有生命周期) 实体
收货地址、金额、坐标 值对象
邮箱、手机号、用户名 值对象(自带格式校验)
日期范围、时间段 值对象
订单项(依附订单存在) 子实体(在聚合内,不独立)
订单项里的"单价" 值对象(Money)
两个"完全相同"是否算同一个 是→值对象;否→实体
八、小结 核心区别 :实体靠身份(ID)判等,值对象靠值判等判断技巧 :在乎"是哪一个"→ 实体;只在乎"值是什么"→ 值对象优先值对象 :不可变、按值判等、构造即合法、组合性强——被低估的工具.NET 实现 :值对象用 record/record struct;实体继承 Entity<TId>强类型 ID :给 ID 套类型,根治"裸 Guid 传错"的 bugEF Core :实体→表/行;值对象→OwnsOne/OwnsMany(无独立身份);按聚合根整体存取字段尽量值对象化 :Email 比 string 安全得多下一篇讲领域事件与事件溯源 ——聚合做完自己的事后,怎么用事件驱动整个系统。
Licensed under CC BY-NC-SA 4.0