用 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。
