领域驱动设计(三):实体与值对象——建模积木的选择

写在前面

上一篇讲聚合时反复出现两个角色——实体(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 传错"的 bug
  • EF Core:实体→表/行;值对象→OwnsOne/OwnsMany(无独立身份);按聚合根整体存取
  • 字段尽量值对象化Emailstring 安全得多

下一篇讲领域事件与事件溯源——聚合做完自己的事后,怎么用事件驱动整个系统。