😺

C#のrecord型で一意な識別子を持ちたい

に公開

値オブジェクトとエンティティ

ドメイン駆動設計の重要な概念である値オブジェクト(Value Object)とエンティティ(Entity)
C#のrecord型は値オブジェクトを実装するために非常に適した機能です。

エンティティを実装する役割は通常のclassになります
reocrd型で実装することは本来の目的から外れた使い方になるのでclassで実装するのがよいでしょう。

「推奨しない」という立場

とはいえ作ってみたい特殊な用途を想定して、実装が可能なのかを試した記事となります。

※結論から言うと制約が強いわりに、誤解を生みやすいですね。「一度作られたら変更されない」という縛りが不要な場合は、素直にclassがよいです。使うとしたら特殊な用途、例えばCQRSパターンのRead側のモデルなどに限定されそうです。

ゴルフクラブをサンプルとして実装します

これ以降は実装例の説明です。
サンプルとして、ゴルフクラブをエンティティとして実装します。
一本一本のクラブは、たとえ同じモデルでもユニークな存在です(シリアルナンバーで識別できます)。
グリップを交換したり、ロフト角を調整したり、傷がついたりしても、それは依然として同じクラブです。属性は変わりますが、アイデンティティ(どのクラブか)は変わりません。

https://gist.github.com/panda728/91ddf269c5018a08526800e38dc65699

抽象クラス

EqualsとGetHashCodeに手を入れることで一意な識別子(Identity)の判定を行います。
判定処理のクラスを2つ定義します。

なお、一意な識別子のプロパティ名はIdとします。

public interface IIdentifiable<out TId>
{
    TId Id { get; }
}

/// <summary>Id による同一性のみを提供するエンティティ基底。順序付けは強制しない。</summary>
public abstract record EntityRecordBase<TSelf, TId> : IIdentifiable<TId>, IEquatable<TSelf>
    where TSelf : EntityRecordBase<TSelf, TId>
    where TId : notnull, IEquatable<TId>
{
    public abstract TId Id { get; init; }

    public virtual bool Equals(TSelf? other)
        => other is not null && Id.Equals(other.Id);

    public override int GetHashCode()
        => Id.GetHashCode();
}

一意な識別子(Identity)をクラス化

識別子となるシリアルナンバーは以下の表現になります。

/// <summary>
/// クラブのシリアルナンバーを表す値オブジェクト。
/// </summary>
public sealed record ClubSerialNumber(string Value)
{
    public override string ToString() => Value;
}

※状況が許すなら readonly record struct を使うのもよいですね

ゴルフクラブの実装サンプル

EntityRecordBaseを継承してエンティティとなるゴルフクラブを実装します。

// エンティティが使用する列挙型
public enum ClubType { Driver, Iron, Wedge, Putter }
public enum GripCondition { New, Good, Worn }

/// <summary>
/// ゴルフクラブを表すエンティティ。
/// プライマリコンストラクタを使用し、簡潔に定義している。
/// </summary>
/// <param name="Id">クラブを一意に識別するシリアルナンバー(ID)</param>
/// <param name="Type">クラブの種類(アイアン、ドライバーなど)</param>
/// <param name="ModelName">モデル名</param>
/// <param name="LoftAngle">ロフト角</param>
public sealed record GolfClub(
    ClubSerialNumber Id, ClubType Type, string ModelName, double LoftAngle
) : EntityRecordBase<GolfClub, ClubSerialNumber>
{
    public override bool Equals(GolfClub? other) => base.Equals(other);
    public override int GetHashCode() => base.GetHashCode();

    // プライマリコンストラクタに含まれないプロパティは、ここで定義・初期化する。
    // 「新しいクラブは、必ず新品のグリップで製造される」というビジネスルールを表現。
    public GripCondition Grip { get; init; } = GripCondition.New;

    /// <summary>
    /// クラブを使用する。
    /// </summary>
    public GolfClub Use()
    {
        return this with { Grip = GripCondition.Good };
    }
    /// <summary>
    /// グリップを交換する。
    /// </summary>
    public GolfClub ReGrip()
    {
        return this with { Grip = GripCondition.New };
    }

    /// <summary>
    /// ロフト角を調整する。
    /// 不正な値が設定されないよう、ビジネスルール(不変条件)をチェックする。
    /// </summary>
    public GolfClub AdjustLoft(double newAngle)
    {
        if (newAngle < 8.0 || newAngle > 60.0)
            throw new ArgumentOutOfRangeException(nameof(newAngle), "ロフト角が不正です。");

        return this with { LoftAngle = newAngle };
    }
}

EqualsメソッドとGetHashCodeメソッドが変更されていることを明示的に記述します。
これはrecord型の基本特性を意図的に変更していることをあらわします。
(隠ぺいすると可読性が下がると考え方です。)

動作の確認

では実装したクラスの使い方のサンプルです。

※実際にはFacotryクラスを作りますが説明のため省略します。

var serialNumber = new ClubSerialNumber("MY7IRON-12345");
var my7Iron = new GolfClub(serialNumber, ClubType.Iron, "Pro-Spec X", 34.0);

Console.WriteLine("【購入直後】");
Console.WriteLine(my7Iron);
Console.WriteLine();

//--- 使用するとグリップがすり減る ---
// Use()メソッドは、グリップ交換後のクラブの状態を表す「新しいオブジェクト」を返す
var used7Iron = my7Iron.Use();

Console.WriteLine("【しばらく使用後】");
Console.WriteLine(used7Iron);
Console.WriteLine();

//--- グリップがすり減ってきたので、交換することにした ---
// ReGrip()メソッドは、グリップ交換後のクラブの状態を表す「新しいオブジェクト」を返す
var regripped7Iron = my7Iron.ReGrip();

Console.WriteLine("【グリップ交換後】");
Console.WriteLine(regripped7Iron); 
Console.WriteLine();

// 別のクラブも登録しておく
var myDriver = new GolfClub(new ClubSerialNumber("MYDRIVER-67890"), ClubType.Driver, "MAX-Flight", 10.5);

//--- 同一性の比較 ---
Console.WriteLine("--- クラブの同一性チェック ---");

// グリップの状態(Grip)は違うが、シリアルナンバー(Id)が同じなので「同じクラブ」と判断される
Console.WriteLine($"購入直後のアイアンと、しばらく使用したアイアンは同じもの? -> {my7Iron.Equals(used7Iron)}"); // true

// グリップの状態(Grip)は違うが、シリアルナンバー(Id)が同じなので「同じクラブ」と判断される
Console.WriteLine($"しばらく使用したアイアンと、グリップ交換後のアイアンは同じもの? -> {used7Iron.Equals(regripped7Iron)}"); // true

// 当然、ドライバーとはシリアルナンバーが違うので「違うクラブ」と判断される
Console.WriteLine($"私の7番アイアンと、ドライバーは同じもの? -> {my7Iron.Equals(myDriver)}"); // false

実行結果

ReGripメソッドで状態変更後のrecordは、変更前とは別ですが、識別子で同一視判定できています。
EntityRecordBaseを継承しているので、通常のrecordとは動作が異なります。

【購入直後】
GolfClub { Id = MY7IRON-12345, Type = Iron, ModelName = Pro-Spec X, LoftAngle = 34, Grip = New }

【しばらく使用後】
GolfClub { Id = MY7IRON-12345, Type = Iron, ModelName = Pro-Spec X, LoftAngle = 34, Grip = Good }

【グリップ交換後】
GolfClub { Id = MY7IRON-12345, Type = Iron, ModelName = Pro-Spec X, LoftAngle = 34, Grip = New }

--- クラブの同一性チェック ---
購入直後のアイアンと、しばらく使用したアイアンは同じもの? -> True
しばらく使用したアイアンと、グリップ交換後のアイアンは同じもの? -> True
私の7番アイアンと、ドライバーは同じもの? -> False

以上が実装サンプルです。

ソートへの対応

つづいてソートへの対応について補足します。
一意の識別子でソートできると便利なので合わせて実装します。

抽象クラス

関心の分離の観点から、ソートにかかわる処理は、EntityRecordBaseとは別で定義します。

/// <summary>Id の自然順序(昇順)が必要な場合だけ利用する比較対応基底</summary>
public abstract record ComparableEntityRecordBase<TSelf, TId> : EntityRecordBase<TSelf, TId>, IComparable<TSelf>
    where TSelf : ComparableEntityRecordBase<TSelf, TId>
    where TId : notnull, IEquatable<TId>, IComparable<TId>
{
    public int CompareTo(TSelf? other)
        => other is null ? 1 : Id.CompareTo(other.Id);
}

一意な識別子(Identity)をクラス化

サンプルとしてスコアカードを実装します。
record型のままではソートできないので、IComparableを実装します。

/// <summary>
/// スコアカードIDを表す値オブジェクト。
/// ID番号で比較可能にするため、IComparable<ScorecardId>を実装する。
/// </summary>
public sealed record ScorecardId(int Value) : IComparable<ScorecardId>
{
    // IComparable<T>インターフェースの実装
    public int CompareTo(ScorecardId other)
    {
        // 比較ロジックを、内部で持つint型のCompareToに委譲する
        return Value.CompareTo(other.Value);
    }

    public override string ToString() => $"SC-{Value:D4}"; // SC-0001 のような形式
}

スコアカードの実装サンプル

スコアカードの実装は以下のようになります。

/// <summary>
/// スコアカードエンティティ。
/// ID(スコアカードID)の自然な順序で並べ替え可能にするため、
/// ComparableEntityRecordBaseを継承する。
/// </summary>
public sealed record Scorecard(
    ScorecardId Id, string PlayerName, string CourseName, int TotalScore, DateTime PlayDate
) : ComparableEntityRecordBase<Scorecard, ScorecardId>
{
    public override bool Equals(Scorecard? other) => base.Equals(other);
    public override int GetHashCode() => base.GetHashCode();
}

動作テスト

実際にソートが可能かを検証したコードは以下になります。
ScorecardIdクラスがソート可能なので、
ScorecardでSort()メソッドが利用できます。(もちろんOrderByも可能)

// スコアカードをIDがバラバラの順序で生成する
var card3 = new Scorecard(new ScorecardId(3), "Suzuki Jiro", "Fuji Course", 95, DateTime.Now);
var card1 = new Scorecard(new ScorecardId(1), "Tanaka Ichiro", "Fuji Course", 88, DateTime.Now);
var card4 = new Scorecard(new ScorecardId(4), "Sato Hanako", "Fuji Course", 102, DateTime.Now);
var card2 = new Scorecard(new ScorecardId(2), "Yamada Taro", "Fuji Course", 92, DateTime.Now);

var scorecards = new List<Scorecard> { card3, card1, card4, card2 };

Console.WriteLine("--- ソート前 (提出された順) ---");
foreach (var card in scorecards)
{
    Console.WriteLine($"ID: {card.Id}, Player: {card.PlayerName}, Score: {card.TotalScore}");
}
Console.WriteLine();

// List<T>.Sort() メソッドを呼び出す
// ScorecardがIComparableを実装しているので、ID順に並べ替えられる
scorecards.Sort();

Console.WriteLine("--- ソート後 (IDの昇順) ---");
foreach (var card in scorecards)
{
    Console.WriteLine($"ID: {card.Id}, Player: {card.PlayerName}, Score: {card.TotalScore}");
}

実行結果

無事ソートされました

--- ソート前 (提出された順) ---
ID: SC-0003, Player: Suzuki Jiro, Score: 95
ID: SC-0001, Player: Tanaka Ichiro, Score: 88
ID: SC-0004, Player: Sato Hanako, Score: 102
ID: SC-0002, Player: Yamada Taro, Score: 92

--- ソート後 (IDの昇順) ---
ID: SC-0001, Player: Tanaka Ichiro, Score: 88
ID: SC-0002, Player: Yamada Taro, Score: 92
ID: SC-0003, Player: Suzuki Jiro, Score: 95
ID: SC-0004, Player: Sato Hanako, Score: 102

期待した動作は実装できました

楽しかったです
record型の基本特性である全項目の値による等価性を崩すことになるので、直感的ではないです。
一応、抽象クラスで「エンティティ」だとわかるようにはしているとはいえ扱いは難しい部類です。

あと、状態変更が多いとどうしても違和感が出てきます。

私が使いたいのは、コアドメインなどです。
複雑な業務ロジックで、classとrecordが混在することで、認知負荷が高く感じたのがきっかけです。

Discussion