C# 强类型ID实现方法 C#如何避免原始类型滥用(Primitive Obsession)

来源:这里教程网 时间:2026-02-21 17:42:08 作者:

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

相关推荐