record struct 是封装强类型 id 的最简实践,零堆分配、值语义安全、编译器自动实现相等性与哈希,支持隐式转换且杜绝 orderid 与 customerid 误用。

用 record struct 封装 ID 类型最简实践
强类型 ID 的核心目标不是“看起来高级”,而是让编译器能拦住 OrderId 和 CustomerId 之间误传、误加、误比较。C# 12 的 record struct 是目前最轻量且零开销的方案——它不分配堆内存,支持值语义,还能自定义 ToString() 和隐式转换。
常见错误是直接继承 struct 并手动实现 IEquatable<t></t> 和运算符重载,既冗长又容易漏掉 GetHashCode() 或比较逻辑不一致。而 record struct 自动生成这些,只用聚焦业务含义:
public record struct OrderId(Guid Value)
{
public static implicit operator Guid(OrderId id) => id.Value;
public static implicit operator OrderId(Guid value) => new(value);
}这样写后,OrderId 和 Guid 可隐式互转,但 OrderId + CustomerId 编译不过,void Process(OrderId id) 也不会被传入裸 Guid 而不加提示。
为什么不用 class 或普通 struct 封装 ID
用 class(如 public sealed class OrderId)会引入不必要的堆分配和 GC 压力,尤其在高吞吐订单系统中,ID 频繁创建/传递时性能敏感;用裸 struct 则丢失相等性语义——两个同值 OrderId 默认按字段逐位比较,看似安全,但一旦后续加字段(比如加个 Version),相等逻辑就意外改变,且无法控制 ToString() 输出格式。
record struct 同时规避了这两点:值类型无 GC,且相等性、哈希、打印行为由编译器基于构造参数(这里是 Value)稳定生成,不随内部字段增减漂移。
- 别给 ID 类型加无意义的属性(如
IsInvalid),这会让使用者困惑“那到底该不该用 == 判断?” - 避免在 ID 类型里塞业务逻辑(如
IsValidForRegion(Region r)),ID 是标识符,不是领域对象 - 如果需要序列化,确保 JSON 库(如 System.Text.Json)已配置支持
record struct的隐式转换,否则可能序列化成{"Value":"..."}而非纯字符串
与 EF Core 集成时绕过原始类型映射陷阱
EF Core 默认把 OrderId 当复杂类型处理,导致迁移生成多余表字段或报错 The property 'Order.Id' is of type 'OrderId' which is not supported by the current database provider。必须显式告诉它“这个 struct 就是 Guid 的别名”:
modelBuilder.Entity<Order>()
.Property(e => e.Id)
.HasConversion<OrderId, Guid>(
id => id.Value,
value => new OrderId(value));注意这里用的是 HasConversion<orderid guid></orderid>,不是 HasConversion<guid></guid>——后者会丢失类型信息,反向转换时无法重建 OrderId 实例。同时确保 OrderId 有公开的 Value 属性且类型为 Guid,否则转换委托编译失败。
- 不要用
[Column(TypeName = "uniqueidentifier")]单独标注属性,EF Core 会忽略它,仍尝试映射整个 struct - 如果数据库字段名不是
Id(比如叫order_id),需额外调用.HasColumnName("order_id"),顺序无关紧要 - 查询时用
Where(x => x.Id == someOrderId)能正常翻译为 SQL,但someOrderId.ToString()在表达式树中不会被识别,得先提取someOrderId.Value
测试时如何验证 Primitive Obsession 是否真正消除
真正的消除标志不是“代码能编译”,而是“改一个 ID 类型定义后,编译器立刻报出所有误用位置”。例如把 OrderId 构造参数从 Guid 改成 long,以下几处必须全部爆红:
- 所有
new OrderId(someGuid)调用 - 所有
ProcessOrder(OrderId id)被传入Guid的地方(即使有隐式转换,改类型后转换也失效) -
Dictionary<orderid ...></orderid>和Dictionary<guid ...></guid>之间的赋值或参数传递
如果某处没报错,说明那里还存在原始类型泄漏——比如方法签名用了 Guid id 而非 OrderId id,或 DTO 中暴露了 public Guid Id { get; set; }。这种地方正是运行时 bug 的温床,比如前端传错 ID 格式,后端却因类型宽松而静默接受。
最易被忽略的是日志和调试输出:哪怕业务层用了 OrderId,如果 logger.LogInformation("Processing {OrderId}", orderId) 里 orderId 被自动拆箱成 Guid 打印,日志里就失去类型上下文,排查时无法区分这是订单 ID 还是用户 ID。










