zukucode
主にWEB関連の情報を技術メモとして発信しています。

C# 型付きIDで異なるIDの渡し間違いを防ぐ

注文IDと顧客IDがどちらもGuidの場合、引数の順序を間違えてもコンパイルできます。

Guidを直接使用する例
await FindOrderAsync(customerId, orderId, cancellationToken);

メソッドの引数も両方Guidなら、値を逆に渡しても型エラーになりません。

今回はIDごとに専用の型を作り、渡し間違いをコンパイル時に検出する方法を紹介します。

record structでIDを定義する

Identifiers.cs
public 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.cs
public Task<Order?> FindAsync(
    OrderId orderId,
    CustomerId customerId,
    CancellationToken cancellationToken)
{
    // データベースから注文を取得する
}

以下の正しい順序ではコンパイルできます。

await repository.FindAsync(orderId, customerId, cancellationToken);

順序を逆にすると、CustomerIdOrderIdへ変換できないためコンパイルエラーになります。

コンパイルエラーになる例
await repository.FindAsync(customerId, orderId, cancellationToken);

文字列から安全に変換する

URLなどから受け取った文字列を変換するため、TryParseを追加します。

OrderId.cs
public 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でGuidstringへ変換します。

レスポンスDTO
public 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の受け渡しを早い段階で検出できます。


関連記事