実務Webアプリ開発編です。Part IV(ドメイン層の実装)として、前回はリポジトリインターフェースをDomain層に置く理由を見てきました。

今回はその続きとして、複数の集約にまたがるビジネスルールをどこに置くかを扱います。

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

  • ドメインサービスが何のためにあるのかを実コードで理解したい
  • ビジネスルールを、「集約・Application層のサービス・ドメインサービス」のどこに置くべきか迷っている
  • 複数集約をまたぐ判定をどう実装するのか具体例を見たい

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

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

これまでの集約・リポジトリに関する記事から続けて読んでもらうと、「集約に置きにくい業務ルール」の位置づけがつかみやすくなります。

【C#/Blazor】実務Webアプリ開発編 (20)DDDのエンティティ・集約・集約ルートを実コードで理解する ~不変条件の守り方~ 実務Webアプリ開発編です。前回は、Emailを題材に値オブジェクトの実装を見てきました。 今回はPart IV(ドメイン層の実...
【C#/Blazor】実務Webアプリ開発編 (21)DDDの集約境界の決め方と守り方 ~TopicとMessageの親子関係を学ぶ~ 実務Webアプリ開発編です。Part IV(ドメイン層の実装)として、前回はUserやMentorshipを題材に、集約ルート自身の状...
【C#/Blazor】実務Webアプリ開発編 (22)DDDのリポジトリとユニットオブワークで集約を保存・取得する ~依存性逆転の原則を学ぶ~ 実務Webアプリ開発編です。Part IV(ドメイン層の実装)として、前回はTopic集約を題材に、親子関係を持つ集約で子エンティティ...
プロ太

前回までで、集約やリポジトリの作り方・使い方をみてきました。

今回は、ドメインサービスを学び、集約・リポジトリとあわせて、Application層からの使われ方もみてみましょう。

ドメインサービスとは?

ドメインサービスが必要になる場面

DDDでは、まずルールを値オブジェクト・エンティティ・集約ルートの中に置けないかを考えます。

たとえば、表示名の更新や状態遷移のように、自分の状態だけを見れば判断できるルールは集約自身に置きやすいです。

プロ太

ここでいう「自分の状態」とは、基本的にはその1つの集約インスタンスが持っている状態のことです。

親子関係のある集約であれば、集約ルート配下の子エンティティの状態も含みます。

一方で、実務ではそれだけでは足りない場面があります。自分の集約だけではなく、他の集約の状態や存在を見ないと判断できないルールです。

よくある例としては、次のようなものです。

  • ユーザー登録時に、同じメールアドレスのユーザーがすでに存在しないか確認する
  • 予約作成時に、同じ時間帯に同じ会議室や担当者の予約が重複していないか確認する
  • MentorAppで、メンタリング作成時の重複や、Activeなメンタリング参加中ユーザーのロール変更可否を確認する

これらのビジネスロジックは、1つの集約だけに自然に閉じ込めにくいため、集約に無理に押し込むと責務が重くなりやすいです。

たとえばUser集約の中に、他のUserやMentorshipの一覧まで見比べる処理を入れ始めると、Userが本来守るべきルールの範囲を越えてしまいます。

その結果、1つの集約が他の集約の状態や存在まで見比べる判断を抱え込み、責務が無秩序に広がりやすくなります。

このような、複数の集約の状態や存在を材料にする業務ルールが、ドメインサービスの候補になります。

プロ美

ユーザー登録で、User集約の中で「同じメールアドレスが既に存在するか」までチェックするのはできないってこと?

プロ太

その通りです。メールアドレスの形式のように、User 自身の値だけで守れるものは集約や値オブジェクトに置けます。

でも、既に同じメールアドレスのUserが存在するかは、1つのUserだけでは判断できず、リポジトリで他のUserの存在を確認する必要があります。

このようなルールは、ドメインサービスとして切り出す候補になります。

Domain層の集約・ドメインサービスとApplication層サービスの役割分担

業務ルール(ビジネスロジック)の記述を、Domain層の集約・ドメインサービスApplication層のサービスでどのように役割分担するのかざっくりみてみましょう。

プロ太

Domain層パートの説明なのですが、Application層のサービスからどう使われるか、どう役割分担するかまでみると、理解がしやすいです。

ここでは、どこにどの判断や手順を置くかという観点で、次のように整理します。

置き場所役割
集約ルート自分の状態だけで判断できるルールと、状態変更を担当するUser の表示名を変更
Mentorship を終了状態へ変更
ドメインサービス複数集約の状態や存在を使う業務ルールを判断する・同じメールアドレスのUserがいれば登録不可と判断
・Active参加中ならUserのロール変更不可と判断
Application層サービスユースケースの手順を組み立て、認可、集約の取得・保存を担当する・ロール変更ユースケースならば、ユーザー認可を行い、リポジトリでUserとActive参加有無を取得し、ドメインサービスで判定し、User集約を変更して保存する

Application層サービスは、操作しているユーザーへの認可を適切に判断しつつ、ユースケースの進行を行います。

具体的には、必要な集約や判定材料をリポジトリから取得し、Domain層のルール(集約、ドメインサービスなど)に従って状態を変更し、最後に保存までを組み立てます。

プロ美

ドメインサービスは、Application層が使う「Domain層にあるいくつかの部品のうちの1つ」ってわけだね!

プロ太

その通りですね。

ドメインサービスは、Domain層で「複数集約にまたがる業務ルール」を担当する部品となります。

MentorAppの実装をみる

ここからは、実際のMentorAppのコードを見ながら、Domain層におけるドメインサービスの実装と、Application層からの使われ方を確認していきます。

Domain層のドメインサービス実装

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

src/MentorApp.Domain/
  Models/
    Users/
      User.cs
      IUserRepository.cs
    Mentorships/
      Mentorship.cs
      IMentorshipRepository.cs
    Topics/
      Topic.cs
      ITopicRepository.cs
    Shared/
      IUnitOfWork.cs
      IUnitOfWorkFactory.cs
  Services/  ←ドメインサービスを配置するフォルダ
    MentorshipDuplicationCheckService.cs
    RoleChangeValidationService.cs 

Models/UsersModels/MentorshipsModels/Topics は、それぞれ集約ごとのモデルを置く場所です。

一方、今回見る MentorshipDuplicationCheckServiceRoleChangeValidationServiceServices/ にあります。

プロ美

集約ごとのフォルダとは別に、複数集約をまたぐルールとして配置されているってことだね!

MentorshipDuplicationCheckService

MentorshipDuplicationCheckService.cs は、Mentorship作成前の重複チェックを担当しています。(コードの要点部分を抜粋しています)

public class MentorshipDuplicationCheckService
{
    public void ValidateMentorshipCreation(
        User mentor,
        User mentee,
        bool hasActiveMentorshipForPair)
    {
        if (mentor.Role != Role.Mentor)
            throw new InvalidOperationException(...);

        if (mentee.Role != Role.Mentee)
            throw new InvalidOperationException(...);

        if (mentor.Id == mentee.Id)
            throw new InvalidOperationException(...);

        if (hasActiveMentorshipForPair)
            throw new InvalidOperationException(...);
    }
}

このメソッドには、ドメインサービスとして判断するための材料が引数で渡されています。具体的には、User 集約2つと、重複有無を表す bool です。

MentorとMenteeの取得や、ActiveなMentorshipの存在確認はApplication層サービス側で行い、その結果をドメインサービスへ渡します。

ドメインサービスは、取得済みの材料を使って業務ルールとして作成してよいかを判断することに集中しています。

ここでのポイントは、単なる存在確認ではなく、ロール検証・同一Userかどうか・既存のActiveなMentorship有無を組み合わせて、Mentorship作成可否を判断している点です。

RoleChangeValidationService

ロール変更可否を検証するのが RoleChangeValidationService.cs です。Activeなメンタリング関係がある場合、不整合が起こらないようにロール変更を禁止しています。

public class RoleChangeValidationService
{
    public void Validate(
        User user,
        Role newRole,
        bool participatesInActiveMentorship)
    {
        if (user.Role == newRole)
            return;

        if (participatesInActiveMentorship)
        {
            throw new InvalidOperationException(
                "Active なメンタリング関係が存在するため、ロールを変更できません。...");
        }
    }
}

こちらも同じで、User と新しいRole、そしてMentorshipへ参加中かどうかの判定材料だけを受け取っています。

ActiveなMentorshipに参加中ならロール変更できないという判定は、User集約単体では完結しないためドメインサービスにしています。

例えば、集約単体で完結しているUser.csChangeRole() などと見比べると、「集約に配置されている業務ルール」と「ドメインサービス」の境目が見えやすいです。

Application層からの呼び出し

次に、Application層のサービスがこれらのドメインサービスをどう使うかを見ます。

MentorshipService.CreateMentorshipAsync()

MentorshipService.cs において「メンタリング関係作成」を行うCreateMentorshipAsync() をみてみましょう。
ドメインサービスの使われ方に着目し、必要な部分を抜粋して示します)

await using var uow = await unitOfWorkFactory.CreateAsync(cancellationToken);

var mentor = await uow.Users.FindByIdAsync(request.MentorUserId, cancellationToken)
    ?? throw new ArgumentException(...);

var mentee = await uow.Users.FindByIdAsync(request.MenteeUserId, cancellationToken)
    ?? throw new ArgumentException(...);

var hasActiveMentorshipForPair = await uow.Mentorships.HasActiveMentorshipAsync(
    mentor.Id,
    mentee.Id,
    cancellationToken);

// ★ここでドメインサービスを使う
// 集めてきた判断材料をドメインサービスへ渡して、判断をゆだねる
duplicationCheckService.ValidateMentorshipCreation(
    mentor,
    mentee,
    hasActiveMentorshipForPair);

var mentorship = new Mentorship(request.MentorUserId, request.MenteeUserId, now);
await uow.Mentorships.AddAsync(mentorship, cancellationToken);
await uow.SaveChangesAsync(cancellationToken);

この流れを見ると、Applicationサービス側ではリポジトリから必要な集約などの情報を取得し、それをドメインサービスに渡して判断を委ねている様子がわかります。

プロ太

Role検証・同一性・重複チェックをまとめた業務判定はドメインサービスに切り出されているわけですね。

HasActiveMentorshipAsync() のようなメソッド名を見ると、リポジトリにも業務ルールが入っているように見えるかもしれません。

ただし、ここでリポジトリが返しているのは「ActiveなMentorshipが存在するか」という事実です。

その事実を使って「作成してよいか」を判断するのが、ドメインサービスの役割です。

UserService.ChangeRoleAsync()UpdateUserAsync()

UserService.cs で「ロール変更」を行うChangeRoleAsync()、「ユーザー情報更新」を行う UpdateUserAsync() メソッドをそれぞれみてみましょう。

どちらも、考え方は先ほどと同じで、Application層サービス側で材料を集めて、それをドメインサービスへ与えて判断を委ねています。

ChangeRoleAsync() は以下のようになっています。

await using var uow = await unitOfWorkFactory.CreateAsync(cancellationToken);

var user = await uow.Users.FindByIdAsync(request.UserId, cancellationToken)
    ?? throw new KeyNotFoundException(...);

var participatesInActiveMentorship = await uow.Mentorships.HasAnyActiveMentorshipByUserIdAsync(
    user.Id,
    cancellationToken);

// ★ここでドメインサービスを使う
// 集めてきた判断材料をドメインサービスへ渡して、判断をゆだねる
roleChangeValidation.Validate(user, request.NewRole, participatesInActiveMentorship);
user.ChangeRole(request.NewRole);

await uow.SaveChangesAsync(cancellationToken);

UpdateUserAsync() は、以下のようになっています。

var hasDisplayNameChanged = request.DisplayName != user.DisplayName;
var hasRoleChanged = request.Role != user.Role;

if (hasDisplayNameChanged)
{
    user.UpdateDisplayName(request.DisplayName);
}

if (hasRoleChanged)
{
    var participatesInActiveMentorship = await uow.Mentorships.HasAnyActiveMentorshipByUserIdAsync(
        user.Id,
        cancellationToken);

    // ★ここでドメインサービスを使う
    // 集めてきた判断材料をドメインサービスへ渡して、判断をゆだねる
    roleChangeValidation.Validate(user, request.Role, participatesInActiveMentorship);
    user.ChangeRole(request.Role);
}

await uow.SaveChangesAsync(cancellationToken);

Application層では以下を行っています。

  • リポジトリから判断・変更対象として必要な集約を取得
  • ドメインサービスを使い変更等を行ってよいか判断
  • 集約に対して変更を実施
  • ユニットオブワークを使って集約への変更を確定(永続化)
プロ美

Application層にはユースケースの手順が残って、ドメインサービスには業務ルール判定がまとまるんだね。

プロ太

その通りです。Application層が必要な材料を集め、Domain層のルールに渡し、最後に集約の状態変更と保存までを組み立てています。

Application層はこの他にも、認可の判断も担っています。Application層について詳しくは、このシリーズ内でも別途解説します。

まとめ

今回は、MentorAppの実コードをもとに、集約をまたぐビジネスルールである「ドメインサービス」について学びました。

ポイントをまとめると次の通りです。

  • ドメインサービスは、1つの集約に自然に置けないビジネスルールをDomain層に表すためのクラス
  • Applicationサービスはユースケースの流れを組み立て、ドメインサービスは業務判定を担当

次回は、Domain層のユニットテストを書きます。詳細な技術実装に依存しないビジネスロジックのコードがどれだけテストしやすいかを見ていきます。

プロ太

引き続き、DDDの中心であるDomain層の実装とテストについて、一緒に学んでいきましょう!

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