DDDを学び始めると、必ず「エンティティ」と「値オブジェクト」でつまずくと思います。
書籍やサンプルでは、record型を使用する例が多いですが、実務ではrecordを使用しない設計をしたい!という場合も多いはずです。
この記事では、recordを使用せずに、実務で使える等価性の設計方法をまとめます。
ざっくりエンティティと値オブジェクトの違い
| 区分 | 意味 | 等価性の基準 | 状態 |
|---|---|---|---|
| Entity | 「誰か(何か)そのもの」 | IDで判定 | 可変 |
| Value Object | 「性質や値の組み合わせ」 | 値の一致で判定 | 不変 |
Entity(例:社員クラス)
同じ名前でも社員番号が異なれば別の人物扱いです。
IDが同じなら属性(名前)が変わっても同一人物として扱います。
public sealed class Employee : EntityBase<Employee>
{
public int Id { get; private set; } // 不変(ORMのValueGenerateOnAddを設定していることを前提)
public string Name { get; private set; }
// EFCore では引数無のコンストラクタが必要
private Employee() { }
// 完全コンストラクタパターン
private Employee(string name)
{
Name = name;
}
// ファクトリメソッドで生成
public static Employee Create(string name)
=> new Employee(name);
// 可変プロパティの操作
public void ReName(string name)
=> this.Name = name;
protected override bool EqualsCore(Employee other)
{
return Id == other.Id;
}
protected override int GetHashCodeCore()
{
return Id.GetHashCode();
}
}
// 識別子(Id)が異なるため、名前が同じでも別の人扱いとなる
// Id は DB 側で自動採番される前提
var yamada1 = Employee.Create("山田太郎"); // Id = 1
var yamada2 = Employee.Create("山田太郎"); // Id = 2
Console.WriteLine(yamada1.Equals(yamada2)); // False
yamada1.ReName("山田花子");
Console.WriteLine(yamada1.Name); // 山田花子
ポイント
- ID基準で等価にする
(IDとかないよ!という場合は、業務キーを等価判定に入れて、IDはサロゲートキー(連番)にするっていう方法もあります) - プロパティを操作する場合は、setterを公開せずに、
ReName()越しに変更する(カプセル化) - 完全コンストラクタパターンを使用することで安全性を上げる
Value Object(例:社員コード)
構成する値が同じなら同一とみなし、不変に設計する。
public sealed class ProductCode : ValueObject<ProductCode>
{
public int Code { get; } // ここは private set; を付ける必要はありません。(後の説明で理解できると思います。)
private ProductCode(int code)
{
// ビジネスロジックはここに配置
if (code < 0)
throw new InvalidInputException("商品コードは、0以上を入力してください。"); // 例外は自作する
Code = code;
}
public static ProductCode Create(int code)
=> new ProductCode(code);
protected override bool EqualsCore(ProductCode other)
{
return Code == other.Code;
}
protected override int GetHashCodeCore()
{
return Code.GetHashCode();
}
}
// Chair1 と Chair2 は同一であるが、
// Chair3は商品コードがことなるので同一でない
var Chair1 = ProductCode.Create(12345);
var Chair2 = ProductCode.Create(12345);
var Chair3 = ProductCode.Create(67890);
Console.WriteLine(Chair1.Equals(Chair2)); // True
Console.WriteLine(Chair1.Equals(Chair3)); // False
ポイント
- 不変オブジェクトとして
Codeはgetterのみ - 等価性の判定にはVOの全プロパティを入れる
- バリデーションなどドメインルールはコンストラクタに集中させる(間違っても
Create()とかに入れたらダメです!)
Baseクラス
EntityBaseの実装
public abstract class EntityBase<T> where T : EntityBase<T>
{
public override bool Equals(object? obj)
{
var vo = obj as T;
if (vo is null)
{
return false;
}
return EqualsCore(vo);
}
public static bool operator ==(EntityBase<T> vo1,
EntityBase<T> vo2)
{
return Equals(vo1, vo2);
}
public static bool operator !=(EntityBase<T> vo1,
EntityBase<T> vo2)
{
return !Equals(vo1, vo2);
}
protected abstract bool EqualsCore(T other);
protected abstract int GetHashCodeCore();
public override string? ToString()
{
return base.ToString();
}
public override int GetHashCode()
{
return GetHashCodeCore();
}
}
ValueObjectの実装
public abstract class ValueObject<T> where T : ValueObject<T>
{
public override bool Equals(object? obj)
{
var vo = obj as T;
if (vo is null)
{
return false;
}
return EqualsCore(vo);
}
public static bool operator ==(ValueObject<T> vo1,
ValueObject<T> vo2)
{
return Equals(vo1, vo2);
}
public static bool operator !=(ValueObject<T> vo1,
ValueObject<T> vo2)
{
return !Equals(vo1, vo2);
}
protected abstract bool EqualsCore(T other);
protected abstract int GetHashCodeCore();
public override string? ToString()
{
return base.ToString();
}
public override int GetHashCode()
{
return GetHashCodeCore();
}
}
まとめ
- 等価性の基準を明確化しておくと、テスト・リポジトリ・集約境界がする
- recordを使用しなくても、堅牢な等価性実装は可能である
- 等価性の判定を間違えるとあり得ないレコードがDBに保存される危険性がある(チーム内でタイポした人がいて、原因究明に4時間以上費やしました。)間違えそうだなという人は
recordを使用するのが安全だと思います。