领域驱动设计中的记录
将值对象、DTO 和领域事件建模为记录,以确保不可变性并提升代码清晰度。
领域驱动设计中的记录 是 CoddyKit 上的免费 C# Academy 课时。 这是第 4 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 C# Academy 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 C# Academy 课程共包含 4 节课。
DDD 与值对象
在领域驱动设计(DDD)中,值对象由其属性而非标识定义。两个 Money(100, "USD") 值可以互换。记录是用 C# 表示值对象的理想方式。
将值对象表示为记录
将领域值对象建模为记录。记录的结构相等性、不可变性和简洁语法与 DDD 原则完美契合。
// Value objects — defined by their values, not identity
public record Money(decimal Amount, string Currency);
public record Email(string Value);
public record PhoneNumber(string CountryCode, string Number);
public record Address(string Street, string City, string Country, string PostalCode);
// Two Money objects with same values are equal:
var price = new Money(99.99m, "USD");
var same = new Money(99.99m, "USD");
Console.WriteLine(price == same); // True在值对象构造函数中进行验证
添加紧凑构造函数来验证不变量。值对象只能在有效状态下创建——无效状态应在构造时抛出异常。
public record Email(string Value)
{
// Compact constructor for validation
public Email : this(Value)
{
if (string.IsNullOrWhiteSpace(Value) || !Value.Contains('@'))
throw new ArgumentException("Invalid email address", nameof(Value));
Value = Value.Trim().ToLowerInvariant();
}
}
// Usage:
var email = new Email(" Alice@Example.COM "); // normalized to alice@example.com
new Email("not-an-email"); // throws ArgumentException实体与值对象
实体具有标识(其 ID 会随时间被追踪);值对象没有标识,只要值相同就可以互换。在 C# 中应以不同方式对它们建模。
// ENTITY: has identity, mutable state
public class Customer
{
public Guid Id { get; } = Guid.NewGuid();
public string Name { get; set; } = "";
public Email Email { get; set; } = default!; // value object
}
// VALUE OBJECT: no identity, immutable
public record Email(string Value);
public record Money(decimal Amount, string Currency);
// Two customers with same name are DIFFERENT entities
// Two Money(100, "USD") are the SAME value包含领域方法的丰富值对象
值对象可以通过方法包含领域逻辑。操作会返回新的值对象,而不是修改现有对象。
public record Money(decimal Amount, string Currency)
{
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new DomainException("Cannot add different currencies");
return this with { Amount = Amount + other.Amount };
}
public Money Subtract(Money other) =>
Amount >= other.Amount
? this with { Amount = Amount - other.Amount }
: throw new DomainException("Insufficient funds");
public Money ApplyDiscount(decimal percent) =>
this with { Amount = Amount * (1 - percent / 100) };
public static Money Zero(string currency) => new(0, currency);
}将领域事件表示为记录
领域事件描述领域中已经发生的事情。记录非常适合表示领域事件:它们不可变、描述性强,并使用结构相等性,从而简化测试。
// Domain events as records
public abstract record DomainEvent(DateTime OccurredAt);
public record OrderPlaced(
Guid OrderId,
Guid CustomerId,
Money Total,
DateTime OccurredAt) : DomainEvent(OccurredAt);
public record OrderShipped(
Guid OrderId,
string TrackingNumber,
DateTime OccurredAt) : DomainEvent(OccurredAt);
public record PaymentReceived(
Guid OrderId,
Money Amount,
DateTime OccurredAt) : DomainEvent(OccurredAt);将 DTO 和 API 契约表示为记录
请求/响应 DTO 本质上是值对象——它们携带数据但没有标识。记录可以使它们简洁且不可变。
// Request DTOs
public record CreateOrderCommand(
Guid CustomerId,
IReadOnlyList<OrderLineDto> Lines);
public record OrderLineDto(Guid ProductId, int Quantity);
// Response DTOs
public record OrderCreatedResponse(
Guid OrderId,
string Status,
decimal Total,
DateTime CreatedAt);
// These can be compared in tests by value:
var expected = new OrderCreatedResponse(id, "Pending", 99m, now);
Assert.Equal(expected, actual);在 EF Core 中存储值对象
使用拥有实体在 EF Core 中持久化值对象。每个值对象的属性都会存储在所有者的表中(或单独的表中),且不会有单独的 PK。
public class Order
{
public int Id { get; set; }
public Money Total { get; set; } = new(0, "USD");
public Address ShipTo { get; set; } = default!;
}
// EF Core config:
modelBuilder.Entity<Order>(e =>
{
e.OwnsOne(o => o.Total, money =>
{
money.Property(m => m.Amount).HasColumnType("decimal(18,2)");
money.Property(m => m.Currency).HasMaxLength(3);
});
e.OwnsOne(o => o.ShipTo);
});包含值对象的聚合根
将这些内容结合起来:使用记录表示值对象和领域事件的 Order 聚合根,并在方法中强制执行所有业务规则。
public class Order
{
private readonly List<DomainEvent> _events = new();
public IReadOnlyList<DomainEvent> DomainEvents => _events;
public Guid Id { get; } = Guid.NewGuid();
public Address ShipTo { get; private set; } = default!; // value obj
public Money Total { get; private set; } = Money.Zero("USD");
public void PlaceOrder(Address shipTo, IEnumerable<OrderLine> lines)
{
ShipTo = shipTo;
Total = lines.Aggregate(Money.Zero("USD"),
(acc, l) => acc.Add(l.Price));
_events.Add(new OrderPlaced(Id, CustomerId, Total, DateTime.UtcNow));
}
}实际案例:类型安全的 ID
使用记录包装基元 ID,防止意外混用不同实体的 ID——这是一种常见的 DDD 技术,称为“强类型 ID”。
// Strongly typed IDs — can't confuse CustomerId with OrderId
public record CustomerId(Guid Value)
{
public static CustomerId New() => new(Guid.NewGuid());
public static implicit operator Guid(CustomerId id) => id.Value;
}
public record OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
}
// Type safety:
void ShipOrder(OrderId orderId, CustomerId customerId) { }
ShipOrder(OrderId.New(), CustomerId.New()); // OK
ShipOrder(CustomerId.New(), OrderId.New()); // COMPILE ERROR!快速检查
DDD 值对象由什么定义?为什么记录非常适合表示值对象?
回顾:领域驱动设计中的记录
要点:
- 值对象:由值定义,没有标识——使用记录
- 实体:具有标识(ID)和可变状态——使用类
- 在紧凑构造函数中验证不变量——无效状态无法表示
- 将领域事件表示为记录:不可变、可比较且具有自描述性
- 使用 EF Core 拥有实体持久化值对象
- 使用记录实现强类型 ID,防止混用实体 ID
用 AI 导师学习 C# — 免费
在浏览器中编写并运行真实代码,获得全天候 AI 导师的即时帮助,并在网页或应用中继续学习。
- 课程
- 93
- 课程
- 346
常见问题解答
「领域驱动设计中的记录」课时是免费的吗?
是的 — 「领域驱动设计中的记录」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 C# Academy 课程的其余内容,请升级到 CoddyKit PRO。 C# Academy 课程共包含 4 节课。
「领域驱动设计中的记录」这节课中我会学到什么?
将值对象、DTO 和领域事件建模为记录,以确保不可变性并提升代码清晰度。 你通过在浏览器中直接运行的动手代码来练习 C# Academy,全天候 AI 导师会在你学习这节课的过程中回答你的问题。
学习 C# Academy 需要有经验吗?
无需任何先前经验。CoddyKit 上的 C# Academy 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 4 节课,共 4 节。
「领域驱动设计中的记录」课时需要多长时间?
大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。
我能在这节 C# Academy 课中编写并运行代码吗?
能。每节 C# Academy 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。
此课程中的所有课时
- 记录类型:基础与语法
- 使用 init 与 with 实现不可变性
- 值相等性与解构
- 领域驱动设计中的记录