【C#/Blazor】実務Webアプリ開発編 (21)集約境界の決め方と守り方 ~TopicとMessageの親子関係をDDDで実装する
実務Webアプリ開発編です。前回の第20回では、UserやMentorshipを題材に、集約ルート自身の状態を安全に変更する実装を見てきました。
今回はPart IV(ドメイン層の実装)の第3回として、親子関係を持つ集約で、子エンティティをどう守るかを見ていきます。
以下のような方に役立つ内容となっています。
- DDDの集約ルートと子エンティティの関係をコードで理解したい
- C#で親子をもつ集約をどう具体的に実装するかを知りたい
- 集約の決め方・判断基準がいまいちわからない
以下のようなMentorAppを題材として進めます。

GitHubにドキュメント・コードの一式があります。
前回記事(集約の基本)から続けてみてもらえると、より理解が深まるかと思います。
前回は、UserやMentorshipを例に、集約ルート自身の状態を安全に変更する方法を見ました。
今回は、集約の中に子エンティティを持つ場合です。まず一般論を整理してから、MentorAppのTopic集約を読んでいきましょう。
最後に「集約の単位をどう決めるか?」の判断基準もお話します。
親子関係を持つ集約では何を守るのか
集約には、1つのエンティティだけで完結するものもあれば、親となるエンティティが子エンティティの一覧を持つものもあります。
たとえば、注文(Order)が複数の注文明細(OrderLine)を持つ、記事(BlogPost)が複数のコメント(Comment)を持つ、といった形です。
こうした構造では、子をただ並べるだけではなく、親の状態に応じて子の操作を制御する必要があります。
今回のテーマは、親子関係を持つ集約で、子エンティティの生成・追加・変更経路を集約ルートに限定する方法です。
子エンティティは単独で扱わない
親子関係を同じ集約に含める大きな理由は、親と子にまたがる不変条件を一貫して守りたいからです。
DDDの集約では、子エンティティは単独で自由に扱うものではありません。
親の状態に依存するなら、親である集約ルートを通して操作します。以下はいずれも、1つの親が複数の子を持つ親子集約の例です。
| 親エンティティ (集約ルート) | 子エンティティ | 守りたいルールの例 |
|---|---|---|
| Order | OrderLine | キャンセル済み注文に明細を追加できない |
| BlogPost | Comment | 受付停止中の記事にコメントできない |
| Topic | Message | クローズ済みトピックにメッセージを投稿できない |
子エンティティにもIDや属性はありますが、外部コードから自由に追加・変更してよいわけではありません。親の状態によって、追加してよいかどうかが変わるからです。
集約ルートは操作の入口になる
前回記事で見たように、集約ルートは集約への入口です。親子関係を持つ集約では、その意味がさらに重要になります。
外部のコード
↓
親エンティティ(集約ルート)
├─ 親自身の状態
└─ 子エンティティの一覧外部のコードは、子エンティティを直接いじるのではなく、集約ルートに「追加して」「閉じて」と依頼します。そこで今の状態で許される操作かを判断します。
言い換えると、集約ルートは単なる親オブジェクトではなく、不変条件を守るための窓口です。
守るべき経路は「生成」「追加」「変更」
子エンティティの操作経路として特に守るべきものは、生成・追加・変更の3つです。
| 経路 | 守らないと起きる問題 | 実装上の対応 |
|---|---|---|
| 生成 | 親のルールを通さず子を作れてしまう | コンストラクタの公開範囲を絞る |
| 追加 | 状態チェックなしで一覧に追加できてしまう | コレクションを直接変更させない |
| 変更 | 本来できない更新を後から実行できてしまう | 変更メソッドを用意しない、または集約ルート経由にする |
この3つを先に意識しておくと、設計の目的がぶれにくくなります。
親子関係を持つ集約では、「子をどう持つか」だけでなく、子の生成・追加・変更を集約ルート経由に限定することを設計します。
子エンティティとコレクションを守る実装パターン
ここからは、C#でよく使う実装パターンを整理します。どれも「子エンティティの操作経路を親エンティティ(集約ルート)に集める」ための工夫です。
生成経路を制限する
子エンティティを外部から自由にnewできると、集約ルートのチェックを通さずにインスタンスを作れてしまいます。
var child = new ChildEntity(parentId, ...);これでは、親の状態チェックをすり抜ける経路が残ります。そこで、コンストラクタを public にしない、あるいは専用メソッドの中で生成する、という方針を取ります。
C#では、子エンティティのコンストラクタを internal や private にすることで、外部コードから勝手に new できなくします。
コレクションを直接変更させない
子エンティティを一覧で持つ場合は、コレクションの公開方法も重要です。private setだけでは十分ではありません。
public List<ChildEntity> Children { get; private set; } = [];この形だと、プロパティの差し替えは防げても、Children.Add(...) は防げません。つまり、追加経路が集約ルートに集まりません。
private readonly List<ChildEntity> _children = [];
public IReadOnlyList<ChildEntity> Children => _children.AsReadOnly();内部では変更可能な List<T> を持ち、外には読み取り専用で公開します。こうすると、追加や削除は集約ルートのメソッドだけが行えます。
変更できない操作はメソッドを用意しない
前回は、変更できるものは専用メソッド経由にする、という考え方を見ました。今回はその裏返しも大事です。
ドメイン上できない操作なら、そもそもメソッドを用意しません。メソッドが存在すること自体が「この操作は許される」という合図になるからです。
// 更新できる設計ならある
public void UpdateContent(string content) { ... }
// 更新できない設計なら、そもそも用意しないつまり、メソッドを持たないこと自体も設計です。後で見る Message がこの考え方に当てはまります。
MentorAppの具体的な実装を見る
ここからは、MentorAppにおける親子関係をもつ集約の実装を見ていきます。中心になるのはTopic.csとMessage.csです。
src/MentorApp.Domain/
Models/
…
Topics/ ←Topic集約のフォルダ
Topic.cs ← 親エンティティ(集約ルート)
Message.cs ← 子エンティティ
TopicStatus.cs
ITopicRepository.cs
…TopicとMessageの関係
Topic はメンターとメンティーの相談トピックです。Message は、その Topic に投稿されるメッセージです。
外部のコード
↓ PostMessage(...)
Topic(集約ルート)
├─ Status: Open / Closed
└─ Messages
├─ Message
└─ Messageここでは Topic が親エンティティ(集約ルート)で、Message は子エンティティです。Messageは単独で存在するのではなく、必ずTopicに属します。
Topic集約が守る不変条件
まずは、Topic集約で何を守りたいのかを整理します。コードの細部を見る前に、不変条件と実装の対応を押さえると読みやすくなります。
| 不変条件 | 実装の工夫 |
|---|---|
| Topic作成時は有効なMentorshipIdとTitleが必要 | Validate(...).ThrowIfInvalid()をコンストラクタで実行する |
| 新規Topicは必ずOpenで始まる | Status = TopicStatus.Open をコンストラクタで設定する |
| ClosedなTopicにはMessageを追加できない | PostMessage() の冒頭で Status をチェックする |
| Messageは必ずTopicに属する | Message のコンストラクタを internal にする |
| Messageは追記のみで扱う | 更新・削除メソッドを持たせない |
このあと、これらの不変条件がコード上でどう表現されているかを順に見ていきます。
Topic集約ルートの実装
先にTopic側を見ると、集約ルートがどこまで責任を持つかが見えてきます。
生成時の不変条件
生成時の検証は、前回見た Validate() → ThrowIfInvalid() のパターンをそのまま使っています。
public Topic(Guid mentorshipId, string? title, DateTimeOffset createdAt)
{
Validate(mentorshipId, title).ThrowIfInvalid();
Id = Guid.NewGuid();
MentorshipId = mentorshipId;
Title = title!;
Status = TopicStatus.Open;
CreatedAt = createdAt;
}Guid.Empty の MentorshipId や空タイトルでは Topic を作れません。また、生成直後の Status は必ず Open です。
Topicは、あるMentorshipに紐づく相談テーマとして作成されます。
ただし、Mentorshipは別集約なので、Topicは Mentorship オブジェクトそのものではなく MentorshipId を持ちます。
Topic集約では、MentorshipId が指定されていることまでを確認します。
参照先の Mentorship が実在するか、Topic を作成できる状態かどうかは、集約の外側で確認する想定です。
Messagesコレクションの保護
TopicがMessage一覧をどう公開しているかを見ると、コレクション保護の意図がはっきり出ています。
private readonly List<Message> _messages = [];
public IReadOnlyList<Message> Messages => _messages.AsReadOnly();実体の List<Message> は private です。外部に公開するのは IReadOnlyList<Message> なので、外から topic.Messages.Add(...) はできません。
こうして、追加できるのは Topic 自身が持つ _messages.Add(...) だけになります。これで追加経路が集約ルートに集まります。
外部コードからTopic経由で、Messageの読み取りだけは行えるってことだね。
そうですね。できなくなるのは Add や Remove のような変更です。
表示は許し、変更は PostMessage() に限定する、という分け方です。
PostMessageでMessageの生成と追加を管理する
Message の生成と追加の入口は、Topic.PostMessage() に集まっています。
public Message PostMessage(Guid senderUserId, string? content, DateTimeOffset sentAt)
{
if (Status == TopicStatus.Closed)
throw new InvalidOperationException("クローズされたトピックにはメッセージを投稿できません。");
var message = new Message(Id, senderUserId, content, sentAt);
_messages.Add(message);
return message;
}最初に Status を見て、今投稿してよい状態かを判断しています。Closed なら、入力値の問題ではなく操作の問題なので InvalidOperationException です。
そのうえで、Messageの生成と _messages への追加を同じメソッドで行っています。これにより、Message追加の入口が1か所にまとまります。
PostMessage() は単なる追加メソッドではありません。
投稿可否の確認、Message生成、一覧への追加をまとめて担うのが集約ルートの役割です。
Closeで状態遷移を管理する
Topic自身の状態遷移も、専用メソッドで制御されています。
public void Close()
{
if (Status == TopicStatus.Closed)
throw new InvalidOperationException("既にクローズされています。");
Status = TopicStatus.Closed;
}Status は private set なので、外から直接 Closed にはできません。必ず Close() を通ります。
状態: Open
├─ PostMessage() できる
└─ Close() できる
状態: Closed
├─ PostMessage() できない
└─ Close() は再実行できないClose() と PostMessage() が組み合わさることで、Topicの状態遷移ルールが作られています。
Message子エンティティの実装
次に Message 側を見ると、「子エンティティは単独で扱わない」という設計がより明確になります。
internalコンストラクタで生成経路を絞る
Message のコンストラクタは public ではなく internal です。
/// <remarks>Topic 集約内部からのみ呼び出される(internal)</remarks>
internal Message(Guid topicId, Guid senderUserId, string? content, DateTimeOffset sentAt)
{
Validate(topicId, senderUserId, content).ThrowIfInvalid();
Id = Guid.NewGuid();
TopicId = topicId;
SenderUserId = senderUserId;
Content = content!;
SentAt = sentAt;
}これにより、MentorApp.Application や MentorApp.Web など、Domainプロジェクトの外からは new Message(...) できません。
Messageを作る入口を Topic.PostMessage() に寄せるための実装です。Topicを経由せずに勝手に子を作る経路を減らしています。
internal は「同じプロジェクト内から呼べる」という意味です。
そのため、言語レベルで「TopicだけMessageをnewできる」ことを完全に保証するわけではありません。
ただし、Application層やWeb層からの直接生成は防げるので、実務上は十分に有効です。
Message自身も生成時に検証する
生成経路をTopicに集めるだけでなく、Message自身の値検証もMessage側で持っています。
public static IEnumerable<ValidationError> Validate(Guid topicId, Guid senderUserId, string? content)
{
return ValidateTopicId(topicId).ToValidationErrors(nameof(TopicId))
.Concat(ValidateSenderUserId(senderUserId).ToValidationErrors(nameof(SenderUserId)))
.Concat(ValidateContent(content).ToValidationErrors(nameof(Content)));
}ここで見ているのは、topicId、senderUserId、content が Message として妥当かどうかです。投稿してよい状態かどうかまでは見ていません。
つまり、Topic.PostMessage() は「今その操作をしてよいか」を見て、Message は「Messageとして妥当な値か」を見る、という責務分担になっています。
編集・削除メソッドを持たない設計
Messageには、更新や削除のためのメソッドがありません。これは実装漏れではなく、追記のみというルールを表しています。
// Message には次のようなメソッドを用意していない
public void UpdateContent(...)
public void Delete(...)投稿済みMessageを編集・削除できないなら、その操作を表すメソッドを置かない。これもドメインルールをコードに反映した形です。
Topic、MessageにはEF Core用のprivateコンストラクタ、ナビゲーションプロパティも実装されています。
これは前回記事でも解説した通り、ドメイン層に技術都合が少し入る現実的な妥協です。
集約の決め方 ~複数の観点を組み合わせて判断する~
前回記事から今回までで、User集約・Mentorship集約・Topic集約をみてきました。ここまで実装を見てくると、あらためて次のような疑問が湧くかもしれません。
- Topic と Message を同じ集約にする理由は?
- UserとMentorshipが別集約だった理由は?
- そもそも集約ってどういう基準で分けるの?
このセクションでは、この集約を決める際の判断基準について少し触れます。
判断の3つの観点
集約境界を考えるときは、次の3つの観点を順に見ると整理しやすくなります。
| 観点 | 見るポイント | 判断の方向 |
|---|---|---|
| ① ライフサイクルの独立性 | 片方が他方なしに存在できるか | 従属していれば同一集約候補、独立なら別集約が自然 |
| ② 不変条件 | ルールを守るために両者の状態を同時に見たいか | 同時参照が必要なら同一集約が有利 |
| ③ 規模 | 子が大量になったときの読み込みや保存コスト | 大きくなりすぎるなら別集約も検討する |
特に大きいのは①と②です。③は実装上の補正で、同一集約にすること自体が重くなりすぎないかを見る観点です。
MentorAppでの判断
MentorAppでは、この3観点を当てはめ 「User / Mentorship」と 「Topic / Message」を例として同一集約/別集約のどちらがよいかを判断してみましょう。
| 観点 | User / Mentorship | Topic / Message |
|---|---|---|
| ① ライフサイクル | Userはメンタリングとは独立して存在する → 別集約が確定 | MessageはTopicに従属し、Topicなしに存在しない → 同一集約候補 |
| ② 不変条件 | ①で別集約確定なので、アプリケーション層で必要な集約を取得し、ドメインサービスで不変条件(MentorロールとMenteeロールの確認など)を担保 → 別集約のまま対応可能 | 「ClosedなTopicには投稿できない」はTopicのStatusとMessage追加が連動 → 同一集約が有利 |
| ③ 規模 | ①で確定済みのため議論不要 | 1つのTopicに大量のMessageが蓄積されるアプリではなく、相談単位のやり取りを想定している → 同一集約でも問題なし |
| 判断 | ①が決め手 → 別集約 | ①②が一致 → 同一集約。 ③で問題なしを確認 |
そのため、MentorAppでは User / Mentorship は別集約にしています。
一方、Topic / Message は同一集約にし、ClosedチェックをDomain層の中で確実に守る設計を選んでいます。
TopicとMessageを別集約にするっていう判断もありえるの?
ありえます。特に、1つのTopicに大量のMessageがぶら下がる前提なら、読み込みや保存の都合から別集約も検討対象になります。
もし別集約にするなら、投稿可否を確認するドメインサービス等を別途用意することになります。
Mentorship と Topic はなぜ別集約なのでしょうか?
実際、Topic は Mentorship に属し(Mentorship無しで成立せず)、Active な Mentorship にだけ Topic を作れる、というルールもあります。
そのため、①ライフサイクルや②不変条件だけを見ると、Mentorship / Topic も同一集約候補に見えます。
ただし、「Mentorship-Topic-Message」という3階層を1つの集約とすると、集約が大きくなりすぎます。
そこで MentorApp では、より操作文脈が近い Topic / Message を同一集約にし、Mentorship / Topic は別集約として扱っています。
Mentorship の状態確認は、Topic 作成時に Application 層の責務で行います。
まとめ
親子関係を持つ集約では、子エンティティを自由に扱わせず、守りたいルールの入口を集約ルートに集めることが大切です。
今回のポイントは以下でした。
- 子エンティティの生成・追加・変更経路は、集約ルートに集める
internalコンストラクタやIReadOnlyList<T>で、外から不変条件を崩せない形にする- 集約境界は、ライフサイクル・不変条件・規模を見て判断する
次回の第22回では、リポジトリインターフェースをDomain層に定義する理由を見ていきます。
なぜ実装ではなくインターフェースをDomain層に置くのかを、依存性逆転の原則とあわせて整理します。
集約ルートは、内部の子エンティティをどう作り、どう追加し、どの状態なら操作を許すかを管理する入口です。
引き続き、リポジトリインターフェースについて一緒に学んでいきましょう。





