C# 型付きIDで異なるIDの渡し間違いを防ぐ
注文IDと顧客IDがどちらもGuidの場合、引数の順序を間違えてもコンパイルできます。
Guidを直接使用する例await FindOrderAsync(customerId, orderId, cancellationToken);メソッドの引数も両方Guidなら、値を逆に渡しても型エラーになりません。
今回はIDごとに専用の型を作り、渡し間違いをコンパイル時に検出する方法を紹介します。
record structでIDを定義する
Identifiers.cspublic readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
public override string ToString() => Value.ToString();
}
public readonly record struct CustomerId(Guid Value)
{
public static CustomerId New() => new(Guid.NewGuid());
public override string ToString() => Value.ToString();
}どちらも内部にはGuidを持ちますが、C#上では異なる型です。
record structを使用すると、値に基づく等価比較も利用できます。
メソッドの引数へ使用する
OrderRepository.cspublic Task<Order?> FindAsync(
OrderId orderId,
CustomerId customerId,
CancellationToken cancellationToken)
{
// データベースから注文を取得する
}以下の正しい順序ではコンパイルできます。
await repository.FindAsync(orderId, customerId, cancellationToken);順序を逆にすると、CustomerIdをOrderIdへ変換できないためコンパイルエラーになります。
コンパイルエラーになる例await repository.FindAsync(customerId, orderId, cancellationToken);文字列から安全に変換する
URLなどから受け取った文字列を変換するため、TryParseを追加します。
OrderId.cspublic readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
public static bool TryParse(string? value, out OrderId result)
{
if (Guid.TryParse(value, out var id))
{
result = new OrderId(id);
return true;
}
result = default;
return false;
}
public override string ToString() => Value.ToString();
}ASP.NET CoreのControllerで明示的に変換する場合は以下のようになります。
OrdersController.cs[HttpGet("{orderId}")]
public async Task<IActionResult> Find(
string orderId,
CancellationToken cancellationToken)
{
if (!OrderId.TryParse(orderId, out var parsedOrderId))
{
return BadRequest("注文IDの形式が正しくありません。");
}
var order = await repository.FindAsync(parsedOrderId, cancellationToken);
return order is null ? NotFound() : Ok(order);
}複数のControllerで使用する場合は、モデルバインダーやIParsable<T>を実装して変換を共通化できます。
データベースでは内部の値を渡す
DapperなどでSQLパラメーターを設定するときは、Valueを渡します。
Dapperの例const string sql = """
SELECT order_id, customer_id, total_amount
FROM orders
WHERE order_id = @OrderId
""";
var order = await connection.QuerySingleOrDefaultAsync<OrderRow>(
sql,
new { OrderId = orderId.Value }
);Entity Framework Coreでは、値コンバーターを設定して型付きIDとデータベースのuuidを変換できます。
JSONへ変換する
そのままシリアライズすると{"value":"..."}のようなオブジェクトになる場合があります。
APIでは文字列として返したい場合、DTOでGuidやstringへ変換します。
レスポンスDTOpublic sealed record OrderResponse(
Guid OrderId,
Guid CustomerId,
decimal TotalAmount
);
var response = new OrderResponse(
order.Id.Value,
order.CustomerId.Value,
order.TotalAmount
);ドメイン内部では型付きIDを使い、外部との境界でプリミティブ型へ変換すると扱いやすくなります。
default値に注意する
構造体にはdefaultが存在するため、new OrderId(Guid.Empty)を作成できます。
空のGuidを禁止したい場合は、生成用メソッドで検証します。
空のGuidを拒否するpublic static OrderId From(Guid value)
{
if (value == Guid.Empty)
{
throw new ArgumentException("空の注文IDは使用できません。", nameof(value));
}
return new OrderId(value);
}型を増やすと変換やシリアライズの設定も増えます。取り違える可能性があり、誤りの影響が大きいIDから適用すると効果を得やすくなります。
型付きIDにより、値の意味をメソッドのシグネチャへ表し、誤ったIDの受け渡しを早い段階で検出できます。