実務Webアプリ開発編です。前回は、Emailを題材に値オブジェクトの実装を見てきました。

今回はPart IV(ドメイン層の実装)の第2回として、エンティティ・集約・集約ルートをMentorAppの実コードで確認していきます。

以下のような方に役立つ内容となっています。

  • DDDにおけるエンティティ・集約・集約ルートについて知りたい
  • C#において、DDDをどう具体的に実装するのかを知りたい
  • DDDの考え方とEF Coreによる実装の関係を知りたい
  • 集約の役割がいまいち理解できない

以下のようなMentorAppを題材として進めます。

GitHubにドキュメント・コードの一式があります。

プロ太

前回は「値そのものにルールを閉じ込める」値オブジェクトを見ました。

今回は、IDで識別されて状態が変わるエンティティと、そのまとまりである集約を整理し、UserとMentorshipの実装を読んでいきましょう。

動画も作成しています。

ドメイン層のフォルダ構成

まずは、今回見るコードがDomain層のどこに置かれているかを確認します。

MentorAppでは、集約ごとにフォルダが分かれており、Users/Mentorships/Topics/の3フォルダは、後で見る3つの集約に対応しています。

src/MentorApp.Domain/
  Models/
    Users/               ←User集約
      User.cs             ← 集約ルート
      Email.cs
      Role.cs
      IUserRepository.cs
    Mentorships/        ←Mentorship集約
      Mentorship.cs       ← 集約ルート
      MentorshipStatus.cs
      IMentorshipRepository.cs
    Topics/              ←Topic集約
      Topic.cs            ← 集約ルート
      Message.cs
      TopicStatus.cs
      ITopicRepository.cs
    Shared/
      ValidationError.cs
      ValidationExtensions.cs
      ValidationHelper.cs
      IUnitOfWork.cs
      IUnitOfWorkFactory.cs
  Services/
    MentorshipDuplicationCheckService.cs
    RoleChangeValidationService.cs

今回はこのうち、User.csMentorship.csを中心に読みます。Shared/には前回から続くバリデーション基盤が置かれています。

エンティティとは何か

エンティティは、IDで識別される業務上の存在です。値オブジェクトとの違いを押さえると、役割が整理しやすくなります。

値オブジェクトとの違い

前回のEmailと今回のUserを比べると、DDDで何を分けて考えているかが見えてきます。

区分識別方法同一性の判断典型例
値オブジェクト値そのもの値が同じなら同じEmailMoney
エンティティIDIDが同じなら同じUserMentorship

同じ表示名を持つUserが2人いても、IDが違えば別ユーザーです。

逆に、同じIDのUserなら表示名が変わっても、同じ存在として追跡できます。

プロ太

値オブジェクトは「中身が同じなら同じもの」、エンティティは「IDが同じなら同じもの」です。

値オブジェクトについては前回記事を参考にしてください。

https://prota-p.com/csharp_web_blazor19_value_object_record/

エンティティの特徴

エンティティには、単に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に含めるのではなく、MentorUserIdMenteeUserId というIdだけを持たせる、というイメージです。

public Guid MentorUserId { get; private set; }
public Guid MenteeUserId { get; private set; }

集約は、それぞれが独立した整合性の境界です。

そのため、別集約のオブジェクトを直接保持すると、意図せず複数集約を同時に変更できてしまい、集約の境界が曖昧になります。

そこで、別集約への参照はIDのみを保持するのが基本となります。

MentorAppの3つの集約

MentorAppのDomain層には、現在3つの集約があります。

集約集約ルート主な構成要素今回の扱い
User集約UserEmailRoleメインで見る
Mentorship集約MentorshipMentorshipStatus概要を確認
Topic集約TopicMessage(エンティティ)、TopicStatus次回扱う

Topic集約の親子関係は次回の第21回で詳しく扱います。今回はUserとMentorshipに絞って、基本パターンを掴みましょう。

プロ太

3フォルダが3集約に対応しているので、コードを開いたときに「どのまとまりのルールか」を追いやすくなっています。

MentorAppの実装を見る

ここからは、実際のコードで「ルールをどこで守っているか」を確認します。

今回の中心はUser.csMentorship.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の採番

DDDでは、エンティティはIDによって識別されます。

Guidはアプリ側で即時生成できるため、DBの採番結果を待たずにエンティティを生成できます。そのため、DDDの実装と相性が良いとされています。

エンティティのIDに何を使うかは、用途によって使い分けるとよいです。

Guidは採番の独立性が高く、推測もされにくい反面、人が目で見ても意味を読み取れません。連番(int/long)はシンプルで扱いやすく、DB側での採番と相性が良いです。

生成時の不変条件

次に、生成時の不変条件をどう守っているかを見ます。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)で値オブジェクトの検証に委ねています。

プロ美

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!;

MentorUserIdMenteeUserId は先述した集約間参照のGuidです。

それに並んで、EF Core のナビゲーションプロパティとして MentorUserMenteeUser が定義されています。

ナビゲーションプロパティがあることで 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における集約の考え方を一緒に学んでいきましょう!

ABOUT ME
プロ太
ソフトウェア開発を楽しく、効率的に行う方法を追求しています。 開発者の視点から技術的課題に向き合い、「純粋な技術的興味に基づく探求」と「実践的な課題解決」という二つの柱を両輪として活動しています。