【C#/Blazor】実務Webアプリ開発編 (20)エンティティ・集約・集約ルートを実コードで理解する ~DDDの不変条件の守り方~
実務Webアプリ開発編です。前回の第19回では、Emailを題材に値オブジェクトの実装を見てきました。
今回はPart IV(ドメイン層の実装)の第2回として、エンティティ・集約・集約ルートをMentorAppの実コードで確認していきます。
以下のような方に役立つ内容となっています。
- DDDにおけるエンティティ・集約・集約ルートについて知りたい
- エンティティや集約という言葉は知っているが、実際のコードにどう落とすか分からない
- C#において、DDDをどう具体的に実装するのかを知りたい
以下のようなMentorAppを題材として進めます。

GitHubにドキュメント・コードの一式があります。
前回は「値そのものにルールを閉じ込める」値オブジェクトを見ました。
今回は、IDで識別されて状態が変わるエンティティと、そのまとまりである集約を整理し、UserとMentorshipの実装を読んでいきましょう。
ドメイン層のフォルダ構成
まずは、今回見るコードがDomain層のどこに置かれているかを確認します。
MentorAppでは、集約ごとにフォルダが分かれており、構造そのものが設計意図を表しています。
src/MentorApp.Domain/
Models/
Users/
User.cs ← 集約ルート
Email.cs
Role.cs
IUserRepository.cs
Mentorships/
Mentorship.cs ← 集約ルート
MentorshipStatus.cs
IMentorshipRepository.cs
Topics/
Topic.cs ← 集約ルート
Message.cs
TopicStatus.cs
ITopicRepository.cs
Shared/
ValidationError.cs
ValidationExtensions.cs
ValidationHelper.cs
IUnitOfWork.cs
IUnitOfWorkFactory.cs
Services/
MentorshipDuplicationCheckService.cs
RoleChangeValidationService.csUsers/・Mentorships/・Topics/の3フォルダは、後で見る3つの集約に対応しています。
今回はこのうち、User.csとMentorship.csを中心に読みます。Shared/には前回から続くバリデーション基盤が置かれています。
エンティティとは何か
エンティティは、IDで識別される業務上の存在です。値オブジェクトとの違いを押さえると、役割が整理しやすくなります。
値オブジェクトとの違い
前回のEmailと今回のUserを比べると、DDDで何を分けて考えているかが見えてきます。
| 区分 | 識別方法 | 同一性の判断 | 典型例 |
|---|---|---|---|
| 値オブジェクト | 値そのもの | 値が同じなら同じ | Email・Money |
| エンティティ | ID | IDが同じなら同じ | User・Mentorship |
同じ表示名やメールアドレスを持つUserが2人いても、IDが違えば別ユーザーです。
逆に、同じIDのUserなら表示名が変わっても、同じ存在として追跡できます。
値オブジェクトは「中身が同じなら同じもの」、エンティティは「IDが同じなら同じもの」です。
値オブジェクトについては前回記事を参考にしてください。
エンティティの特徴
エンティティには、単にIDを持つだけでなく、状態やルールを内側で守る役割があります。
| 特徴 | 意味 | うれしさ |
|---|---|---|
| 同一性 | GuidなどのIDで識別する | 状態が変わっても同じ存在として追える |
| 可変性 | 表示名変更などの状態遷移を持つ | ライフサイクルを表現できる |
| 不変条件の保護 | 生成時や更新時にルールを確認する | 不正な状態のまま残りにくい |
つまり、エンティティは「データの入れ物」ではなく、業務ルールを持つオブジェクトとして設計します。
集約と集約ルートとは何か
エンティティが1つずつ独立しているだけでは、関連するルールを守りきれない場面があります。
そこでDDDでは、関連するエンティティや値オブジェクトを集約としてまとめ、入り口となる集約ルートを決めます。
集約:一貫性の境界
集約とは、関連するエンティティや値オブジェクトをひとまとまりにした、一貫性を保つ単位です。
重要なのは、集約の中では不変条件が常に守られていることです。以下は例です。
| 集約 | 不変条件の例 |
|---|---|
| Mentorship集約 | メンターとメンティーは別人である。Active状態のみ完了・キャンセル可能 |
| Topic集約 | ClosedなTopicにはMessageを追加できない(詳細は次回) |
この「守るべきルールの範囲」が集約の境界です。後で保存するときの単位にもつながっていきます。
集約の境界は、まず何の不変条件を守りたいかから考えます。
その結果として、集約内の変更は不整合な状態が残らないよう1トランザクションでまとめて保存したい、という考え方に自然につながります。
次回以降で扱う「1集約=1リポジトリ」もこの考え方につながります。
集約ルート:集約への唯一の入り口
集約ルートは、集約の外から見える入り口になるエンティティです。外部のコードは、集約の内部を直接いじらず、集約ルートのメソッド経由で操作します。
例えば以下のようなイメージです。
【User集約:シンプルな例】
外部のコード
↓
集約ルート(User)
└─ 集約内の値(Email・Role)
【Topic集約:複合的な例(詳細は次回)】
外部のコード
↓
集約ルート(Topic)
├─ 集約内のエンティティ(Message)
└─ 集約内の値(TopicStatus)入り口を1つに絞ることで、チェック漏れの経路を減らし、不変条件を守りやすくできます。
集約は「ルールが成り立つまとまり」全体です。集約ルートは、そのまとまりに入るための窓口になるエンティティです。
別集約への参照
別集約を参照するときは、オブジェクトをそのまま持つのではなく、IDだけを保持して参照関係を表します。
Mentorshipを例にすると、UserオブジェクトをMentorshipに含めるのではなく、MentorUserId・MenteeUserId というIdだけを持たせる、というイメージです。
public Guid MentorUserId { get; private set; }
public Guid MenteeUserId { get; private set; }MentorAppの3つの集約
MentorAppのDomain層には、現在3つの集約があります。
| 集約 | 集約ルート | 主な構成要素 | 今回の扱い |
|---|---|---|---|
| User集約 | User | Email、Role | メインで見る |
| Mentorship集約 | Mentorship | MentorshipStatus | 概要を確認 |
| Topic集約 | Topic | Message(エンティティ)、TopicStatus | 次回扱う |
この表を見ると、フォルダ構成がそのまま集約の分け方に対応していることが分かります。
Topic集約の親子関係は次回の第21回で詳しく扱います。今回はUserとMentorshipに絞って、基本パターンを掴みましょう。
3フォルダが3集約に対応しているので、コードを開いたときに「どのまとまりのルールか」を追いやすくなっています。
MentorAppの実装を見る
ここからは、実際のコードで「ルールをどこで守っているか」を確認します。
今回の中心はUser.csとMentorship.csです。
Userエンティティ(集約ルート)
User.csは、User集約の集約ルートです。
クラス宣言とプロパティ設計
まず見るべきなのは、クラス宣言とプロパティの持ち方です。前回のrecord Emailとは違い、Userはclassで実装されています。
public class User
{
public const int ExternalIdMaxLength = 255;
public const int DisplayNameMaxLength = 100;
public Guid Id { get; private set; }
public string ExternalId { get; private set; } = null!;
public string DisplayName { get; private set; } = null!;
public Email Email { get; private set; } = null!;
public Role Role { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
// EF Core 用
private User() { }
public User(string? externalId, string? displayName, DateTimeOffset createdAt, string? email, Role role = Role.Mentee)
{
Validate(externalId, displayName, email).ThrowIfInvalid();
Id = Guid.NewGuid();
ExternalId = externalId!;
DisplayName = displayName!;
Email = new Email(email);
Role = role;
CreatedAt = createdAt;
}
}エンティティはIDで識別するため、recordの構造的等価性は主役ではありません。そのため、ここではclassが自然です。
また、すべてのプロパティをprivate setとし、外部から直接変更できないようにしています。
プロパティをprivate setにするのは、そんなに大事なの?
user.DisplayName = ""のように外から直接書けると、バリデーションを通さず状態を壊せます。
メソッド経由に限定し、そこで集約の不変条件が満たされているか確実に検証できるようにしているのです。
IDの採番
IdはGuid型で、コンストラクタ内でId = Guid.NewGuid()としているため、DBに採番を依頼せずエンティティ自身が生成時にIDを決めることができます。
DDDでは集約ルートが自分のIDを持つ責任を担うため、アプリ側で即時生成できるGuidはこの思想と相性が良いです。
エンティティのIDに何を使うかは用途によって使い分けるとよいです。
Guidは採番の独立性が高く推測もされにくい反面、人が目で見ても意味を読み取れません。連番(int/long)はシンプルで扱いやすくDB側での採番と相性が良いです。
また「年月日+連番」のような伝票番号・注文番号は、人が見て意味のわかる値をそのままIDとして使うパターンで、業務帳票との整合を優先する場面で選ばれます。
生成時の不変条件
次に、生成時の不変条件をどう守っているかを見ます。MentorAppでは前回と同じValidate() → ThrowIfInvalid()の流れを使っています。
public static IEnumerable<ValidationError> Validate(
string? externalId, string? displayName, string? email)
{
return ValidateExternalId(externalId).ToValidationErrors(nameof(ExternalId))
.Concat(ValidateDisplayName(displayName).ToValidationErrors(nameof(DisplayName)))
.Concat(Email.Validate(email).ToValidationErrors(nameof(Email)));
}前回のEmailでは1つの値を検証していましたが、Userでは複数プロパティのエラーをConcatでまとめています。
そのうえでコンストラクタの最初でThrowIfInvalid()を呼ぶので、不正なUserは生成できません。
Email.Validate(email)を呼んでいる点も重要です。Userの中に値オブジェクトのルールがきちんと組み込まれています。
ロールの種類は列挙型で絞られているため不正な値は型レベルで弾かれますが、どのロールで生成するかの責任は呼び出し側にあります。
状態変更メソッド
状態変更も、専用メソッド経由に限定されています。
public void UpdateDisplayName(string? displayName)
{
ValidateDisplayName(displayName).ToValidationErrors(nameof(DisplayName)).ThrowIfInvalid();
DisplayName = displayName!;
}
public void UpdateEmail(string email) => Email = new Email(email);
/// <remarks>認可チェックはアプリケーション層(UserService)で行うため、このメソッド自体は認可を持たない。</remarks>
public void ChangeRole(Role newRole) => Role = newRole;UpdateDisplayName()は更新前に検証します。UpdateEmail()はnew Email(email)で値オブジェクトの検証に委ねています。
つまり、どこから更新しても、Emailのルールは必ず同じ場所を通ります。
ChangeRole()だけ、チェックが薄く見えるけど大丈夫?
Roleは列挙型なので、型の時点で取りうる値が絞られています。
一方で「誰がロール変更してよいか」という認可は別問題なので、コメントどおりApplication層で扱います。
EF Core用プライベートコンストラクタ
もう1つ実務的に大事なのが、EF Core用のプライベートコンストラクタです。
// EF Core 用
private User() { }EF Coreがインスタンスを復元する際に使うコンストラクタです。アプリコードから呼ばせたくないため、privateにしています。
あれ?ドメイン層は純粋で「実装非依存」だったはずでは?EF Core用って思いっきり個別の実装に依存してるよね…。
そこに気づくのは鋭いですね!
EF Coreで永続化する現実的な実装では、このような最小限の妥協(技術詳細を意識して実装)がよくあります。
原則に従うことを優先すると、逆に実装が複雑になってしまうような場合には、ある程度の妥協も必要です。
MentorAppでは「// EF Core 用」とコメントで明示して、ドメインロジック本体と区別しています。
Mentorship集約ルート
次にMentorship.csを見ます。
Userと同じく集約ルートですが、こちらは状態遷移のルールがよりはっきり出ています。
生成時の不変条件
public Mentorship(Guid mentorUserId, Guid menteeUserId, DateTimeOffset startedAt)
{
Validate(mentorUserId, menteeUserId).ThrowIfInvalid();
Id = Guid.NewGuid();
MentorUserId = mentorUserId;
MenteeUserId = menteeUserId;
Status = MentorshipStatus.Active;
StartedAt = startedAt;
}
public static IEnumerable<ValidationError> Validate(Guid mentorUserId, Guid menteeUserId)
{
return ValidateMentorUserId(mentorUserId).ToValidationErrors(nameof(MentorUserId))
.Concat(ValidateMenteeUserId(menteeUserId).ToValidationErrors(nameof(MenteeUserId)))
.Concat(ValidateDifferentUsers(mentorUserId, menteeUserId).ToValidationErrors(nameof(MenteeUserId)));
}ここでも、生成時にValidate() → ThrowIfInvalid()を通しています。
特に重要なのは、メンターとメンティーは別人でなければならないというルールを、Mentorship自身が持っていることです。
また、生成直後にStatus = MentorshipStatus.Activeとしており、初期状態もエンティティの中で決めています。
状態遷移メソッド
状態遷移メソッドは次のようになっています。
public void Complete(DateTimeOffset endedAt)
{
if (Status != MentorshipStatus.Active)
throw new InvalidOperationException("Active 状態の Mentorship のみ完了にできます。");
Status = MentorshipStatus.Completed;
EndedAt = endedAt;
}
public void Cancel(DateTimeOffset endedAt)
{
if (Status != MentorshipStatus.Active)
throw new InvalidOperationException("Active 状態の Mentorship のみキャンセルできます。");
Status = MentorshipStatus.Cancelled;
EndedAt = endedAt;
}ここでは入力値の検証ではなく、今の状態でその操作が許されるかを見ています。
そのため、失敗時はArgumentExceptionではなくInvalidOperationExceptionを投げています。
この使い分けにより、「入力が悪い」のか「操作の順番が悪い」のかを区別しやすくなります。
ArgumentExceptionは入力不正、InvalidOperationExceptionは操作不正という意図で読むと、エンティティの責務が見えやすくなります。
EF Coreナビゲーションプロパティ
public Guid MentorUserId { get; private set; }
public User MentorUser { get; private set; } = null!;
public Guid MenteeUserId { get; private set; }
public User MenteeUser { get; private set; } = null!;MentorUserId・MenteeUserId は先述した集約間参照のGuidです。
それに並んで、EF Core のナビゲーションプロパティとして MentorUser・MenteeUser が定義されています。
ナビゲーションプロパティがあることで mentorship.MentorUser.DisplayName のように参照先の実体に簡単にアクセスできます。
プライベートコンストラクタと同様、EF Core 利用に伴う実装上の対応です。
ここも、技術詳細(EF Core)の都合がドメイン層へ染み出しているってことだね。
その通りですね。ナビゲーションプロパティやその使い方については、インフラ層実装(EF Coreの活用)のPartで詳しく解説します。
まとめ
今回は、MentorAppのUserとMentorshipを通じて、エンティティ・集約・集約ルートの考え方と実装パターンを確認しました。
共通しているのは、ルールをオブジェクトの内側に閉じ込めるという考え方です。
不正な状態を作らせず、正しい経路以外からは状態を変えられないようにすることで、業務ルールを安全に守ります。
- エンティティはIDで識別される:
値オブジェクトと違い、状態が変わっても同じ存在として追跡できる。classで実装し、IDで同一性を判断する - 集約は不変条件を守る単位で、集約ルートが唯一の入り口:
外部のコードは集約ルートのメソッドを通じてのみ操作し、内部を直接変更させない - 生成時はコンストラクタで不変条件を検証する:
Validate() → ThrowIfInvalid()のパターンで、不正なオブジェクトが作られないようにする - 状態変更はprivate set+専用メソッドで制御する:
更新経路を絞り、どこから変更しても必ず検証を通す。
今回はUser・Mentorshipという、それぞれ1つのエンティティで構成されるシンプルな集約を学びました。
次回は、TopicとMessageの親子関係を持つ集約を見ていきます。internalコンストラクタやIReadOnlyListによる保護が次のポイントです。
集約という「ルールが成り立つまとまり」を意識すると、コードのどこにルールを書くべきかが見えやすくなります。
引き続き、DDDにおける集約の考え方を一緒に学んでいきましょう!






