跳转到内容

值对象

值对象(Value Object)是领域驱动设计中的重要概念。与实体不同,值对象通过其属性值来标识,而不是通过唯一标识符(Id)。

值对象具有以下特征:

  1. 无唯一标识:没有 Id 属性,通过属性值来识别
  2. 不可变性:一旦创建,属性值不能改变
  3. 值相等性:两个值对象相等当且仅当所有属性值都相同
  4. 可替换性:可以用另一个相同值的对象替换
  5. 无副作用:方法不改变对象状态,返回新对象

MiCake 提供了 ValueObject 抽象类用于定义值对象:

using MiCake.DDD.Domain;
using System.Collections.Generic;
public class Money : ValueObject
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency ?? throw new ArgumentNullException(nameof(currency));
}
// 定义相等性比较的组件
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Amount;
yield return Currency;
}
// 业务方法返回新对象,保持不可变性
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new DomainException("Cannot add money with different currencies");
return new Money(Amount + other.Amount, Currency);
}
public Money Multiply(decimal multiplier)
{
return new Money(Amount * multiplier, Currency);
}
public override string ToString() => $"{Amount} {Currency}";
}

对于简单的值对象,可以使用 C# 9.0+ 的 record 类型:

using MiCake.DDD.Domain;
// 使用 record 简化值对象定义
public record Address : RecordValueObject
{
public string Street { get; init; }
public string City { get; init; }
public string ZipCode { get; init; }
public string Country { get; init; }
public Address(string street, string city, string zipCode, string country)
{
Street = street ?? throw new ArgumentNullException(nameof(street));
City = city ?? throw new ArgumentNullException(nameof(city));
ZipCode = zipCode ?? throw new ArgumentNullException(nameof(zipCode));
Country = country ?? throw new ArgumentNullException(nameof(country));
}
}

RecordValueObject 的优势:

  • 自动实现值相等性
  • 自动实现 GetHashCode
  • 自动实现解构
  • 支持 with 表达式
  • 更简洁的语法
public class Address : ValueObject
{
public string Street { get; }
public string City { get; }
public string ZipCode { get; }
public Address(string street, string city, string zipCode)
{
Street = street;
City = city;
ZipCode = zipCode;
}
// 返回用于比较的所有属性
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Street;
yield return City;
yield return ZipCode;
}
}
// 使用示例
var address1 = new Address("123 Main St", "Beijing", "100000");
var address2 = new Address("123 Main St", "Beijing", "100000");
var address3 = new Address("456 Park Ave", "Shanghai", "200000");
Console.WriteLine(address1 == address2); // True - 所有属性相同
Console.WriteLine(address1 == address3); // False - 属性不同
public class DateRange : ValueObject
{
public DateTime StartDate { get; }
public DateTime EndDate { get; }
public DateRange(DateTime startDate, DateTime endDate)
{
if (endDate < startDate)
throw new DomainException("End date must be after start date");
StartDate = startDate;
EndDate = endDate;
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return StartDate;
yield return EndDate;
}
public int GetDays() => (EndDate - StartDate).Days;
public bool Contains(DateTime date)
{
return date >= StartDate && date <= EndDate;
}
public bool Overlaps(DateRange other)
{
return StartDate <= other.EndDate && EndDate >= other.StartDate;
}
}
public class PersonName : ValueObject
{
// 只读属性
public string FirstName { get; }
public string LastName { get; }
public string FullName => $"{FirstName} {LastName}";
public PersonName(string firstName, string lastName)
{
FirstName = firstName ?? throw new ArgumentNullException(nameof(firstName));
LastName = lastName ?? throw new ArgumentNullException(nameof(lastName));
}
// 方法返回新对象,不修改当前对象
public PersonName ChangeFirstName(string newFirstName)
{
return new PersonName(newFirstName, LastName);
}
public PersonName ChangeLastName(string newLastName)
{
return new PersonName(FirstName, newLastName);
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return FirstName;
yield return LastName;
}
}
// 使用示例
var name = new PersonName("张", "三");
var newName = name.ChangeFirstName("李"); // 返回新对象
Console.WriteLine(name.FirstName); // 张 - 原对象未改变
Console.WriteLine(newName.FirstName); // 李 - 新对象
public class Money : ValueObject
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
if (amount < 0)
throw new DomainException("Amount cannot be negative");
Amount = amount;
Currency = currency?.ToUpper()
?? throw new ArgumentNullException(nameof(currency));
}
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new DomainException($"Cannot add {other.Currency} to {Currency}");
return new Money(Amount + other.Amount, Currency);
}
public Money Subtract(Money other)
{
if (Currency != other.Currency)
throw new DomainException($"Cannot subtract {other.Currency} from {Currency}");
return new Money(Amount - other.Amount, Currency);
}
public Money Multiply(decimal multiplier)
{
return new Money(Amount * multiplier, Currency);
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Amount;
yield return Currency;
}
public static Money Zero(string currency) => new Money(0, currency);
public static Money CNY(decimal amount) => new Money(amount, "CNY");
public static Money USD(decimal amount) => new Money(amount, "USD");
public override string ToString() => $"{Amount:F2} {Currency}";
}
public class Address : ValueObject
{
public string Country { get; }
public string Province { get; }
public string City { get; }
public string Street { get; }
public string ZipCode { get; }
public Address(string country, string province, string city, string street, string zipCode)
{
Country = country ?? throw new ArgumentNullException(nameof(country));
Province = province ?? throw new ArgumentNullException(nameof(province));
City = city ?? throw new ArgumentNullException(nameof(city));
Street = street ?? throw new ArgumentNullException(nameof(street));
ZipCode = zipCode ?? throw new ArgumentNullException(nameof(zipCode));
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Country;
yield return Province;
yield return City;
yield return Street;
yield return ZipCode;
}
public override string ToString()
{
return $"{Country}, {Province}, {City}, {Street}, {ZipCode}";
}
}
public class EmailAddress : ValueObject
{
public string Value { get; }
public EmailAddress(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new DomainException("Email address cannot be empty");
if (!value.Contains("@") || !value.Contains("."))
throw new DomainException("Invalid email format");
Value = value.ToLower().Trim();
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Value;
}
public string GetDomain()
{
return Value.Split('@')[1];
}
public override string ToString() => Value;
// 隐式转换
public static implicit operator string(EmailAddress email) => email.Value;
}
public class Customer : AggregateRoot<int>
{
// 值对象属性
public PersonName Name { get; private set; }
public EmailAddress Email { get; private set; }
public PhoneNumber Phone { get; private set; }
public Address ShippingAddress { get; private set; }
private Customer() { }
public static Customer Create(PersonName name, EmailAddress email)
{
return new Customer
{
Name = name,
Email = email
};
}
public void UpdateEmail(EmailAddress newEmail)
{
if (newEmail == null)
throw new ArgumentNullException(nameof(newEmail));
Email = newEmail;
RaiseDomainEvent(new CustomerEmailChangedEvent(Id, newEmail.Value));
}
public void UpdateShippingAddress(Address newAddress)
{
ShippingAddress = newAddress;
}
}
public class Order : AggregateRoot<int>
{
private Money _totalAmount;
private Address _shippingAddress;
public void UpdateShippingAddress(Address newAddress)
{
if (newAddress == null)
throw new ArgumentNullException(nameof(newAddress));
_shippingAddress = newAddress;
}
public void ApplyDiscount(Percentage discountRate)
{
var discount = discountRate.ApplyTo(_totalAmount);
_totalAmount = _totalAmount.Subtract(discount);
RaiseDomainEvent(new DiscountAppliedEvent(Id, discount));
}
}
public class CustomerConfiguration : IEntityTypeConfiguration<Customer>
{
public void Configure(EntityTypeBuilder<Customer> builder)
{
// 方式一:拆分为多个列
builder.OwnsOne(c => c.Name, name =>
{
name.Property(n => n.FirstName)
.HasColumnName("FirstName")
.HasMaxLength(50);
name.Property(n => n.LastName)
.HasColumnName("LastName")
.HasMaxLength(50);
});
// 方式二:使用 JSON 存储
builder.OwnsOne(c => c.Address, address =>
{
address.ToJson();
});
// 方式三:使用转换器
builder.Property(c => c.Email)
.HasConversion(
email => email.Value,
value => new EmailAddress(value)
);
}
}
特性值对象实体
标识无唯一标识有唯一 Id
相等性基于属性值基于 Id
可变性不可变可变
生命周期无独立生命周期有独立生命周期
可替换性可以被相同值的对象替换不能被替换

使用值对象

  • 描述事物的属性或度量
  • 不需要追踪变更历史
  • 可以被相同值的对象替换
  • 例如:金额、地址、日期范围、邮箱

使用实体

  • 需要唯一标识
  • 需要追踪变更历史
  • 有独立的生命周期
  • 例如:用户、订单、商品
// ✅ 好的做法 - 简单明了
public class Temperature : ValueObject
{
public decimal Value { get; }
public string Unit { get; }
public Temperature(decimal value, string unit)
{
Value = value;
Unit = unit;
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Value;
yield return Unit;
}
}
// ❌ 避免 - 过于复杂
public class ComplexValue : ValueObject
{
// 包含太多属性和复杂逻辑
// 可能应该拆分为多个值对象或变成实体
}
public class Age : ValueObject
{
public int Value { get; }
public Age(int value)
{
if (value < 0)
throw new DomainException("Age cannot be negative");
if (value > 150)
throw new DomainException("Age seems unrealistic");
Value = value;
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Value;
}
}
public class Money : ValueObject
{
public decimal Amount { get; }
public string Currency { get; }
private Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency;
}
// 工厂方法
public static Money Create(decimal amount, string currency)
{
if (amount < 0)
throw new DomainException("Amount cannot be negative");
return new Money(amount, currency);
}
public static Money Zero(string currency) => new Money(0, currency);
public static Money CNY(decimal amount) => Create(amount, "CNY");
public static Money USD(decimal amount) => Create(amount, "USD");
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Amount;
yield return Currency;
}
}
public class DateRange : ValueObject
{
public DateTime StartDate { get; }
public DateTime EndDate { get; }
public DateRange(DateTime startDate, DateTime endDate)
{
if (endDate < startDate)
throw new DomainException("End date must be after start date");
StartDate = startDate;
EndDate = endDate;
}
// 有业务含义的方法
public int GetDurationInDays() => (EndDate - StartDate).Days;
public bool Contains(DateTime date) =>
date >= StartDate && date <= EndDate;
public bool Overlaps(DateRange other) =>
StartDate <= other.EndDate && EndDate >= other.StartDate;
public DateRange ExtendBy(int days) =>
new DateRange(StartDate, EndDate.AddDays(days));
protected override IEnumerable<object> GetEqualityComponents()
{
yield return StartDate;
yield return EndDate;
}
}
// 错误:属性可以被修改
public class Address : ValueObject
{
public string Street { get; set; } // 不要用 set
public string City { get; set; }
}
public class Address : ValueObject
{
public string Street { get; } // 只读
public string City { get; }
public Address(string street, string city)
{
Street = street;
City = city;
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Street;
yield return City;
}
}

值对象是 DDD 中的重要概念,在 MiCake 中:

  • 继承 ValueObject 基类或 RecordValueObject
  • 通过属性值比较相等性
  • 保持不可变性
  • 在实体中作为属性使用
  • 封装领域概念和业务规则
  • 使代码更具表达力和类型安全

下一步: