実務Webアプリ開発編です。今回のテーマは、アプリケーションサービスに何を書き、何を書かないかです。

シリーズとしては、前回でユニットオブワークという「保存を確定する部品」が完成しました。今回はそれを使う側、つまりユースケースそのものを主役にします。

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

  • アプリケーションサービスに何を書けばよいのか、毎回迷う
  • 認可(権限チェック)をどの層に置くべきか判断できない
  • ドメインサービスを呼ぶとき、DBからの取得を誰がやるのか整理したい
  • 1回の保存でどこまでをまとめるのか、その範囲の決め方を知りたい

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

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

ドメインサービスの役割と、リポジトリ・ユニットオブワークの実装が土台になります。以下の記事とつなげて読むと理解しやすいです。

【C#/Blazor】実務Webアプリ開発編 (23)DDDのドメインサービスとは?~集約をまたぐビジネスルール~ 実務Webアプリ開発編です。Part IV(ドメイン層の実装)として、前回はリポジトリインターフェースをDomain層に置く理由を見て...
【C#/Blazor】実務Webアプリ開発編 (28)リポジトリパターンをEF Coreで実装する ~コマンド側の集約取得と保存入口を作る~ 実務Webアプリ開発編です。今回のテーマは、DDD(ドメイン駆動設計)において、リポジトリパターンをEF Coreで実装する方法です。...
【C#/Blazor】実務Webアプリ開発編 (29)ユニットオブワークをEF Coreで実装する ~複数リポジトリの変更を1回の保存で確定する~ 実務Webアプリ開発編です。今回のテーマは、DDD(ドメイン駆動設計)において、ユニットオブワーク(Unit of Work)をEF ...

なお第18回では、1回の処理が4つの層をどう通っていくかをデバッガで追いました。

今回はその通り道のうち、1つの機能を成立させる司令塔にあたる「ユースケース」に何を置くのか、という話です。

プロ太

1つのユースケースに何を書き、何を書かないのかを整理する回です。認可・例外処理・ログも含めて、ユースケース全体をみていきましょう!

Application層はどこにいるか

「メンタリングを作成する」「メッセージを投稿する」のように、利用者がしたいこと一件を、最初から最後まで通す手続きをユースケースと呼びます。

それを1つのメソッドとして持つ部品がアプリケーションサービスです。

この記事で扱うのは、状態を変えるユースケースです。一覧取得のような読み取り専用の経路は別の形をしているので、次回以降で扱います。

まず、ユースケース1件が周囲とどうつながっているかをみます。

ユースケース1件が通る道筋

アプリケーションサービスにつながっている相手は3つです。この3つを1つずつみていきます。

  • Web層(画面)から呼ばれ、要求を受け取って結果を返す
  • Domain層に判断と状態変更を任せる
  • Infrastructure層で取り出し・保存・記録を行う

画面はユースケースを呼ぶだけ

画面がアプリケーションサービスに渡すのは、何をするか誰がするかの二つだけです。入力中フラグや選択行といった画面の状態は渡しません。

返ってくるのも処理の結果であり、画面に出す文言ではありません。「登録しました」という文字列を組み立てるのは画面の仕事です。

だから表示文言、画面遷移、入力フォームの状態はここに書きません。これらはWeb層に残ります。

判断と状態変更はDomain層に任せる

「在庫が足りているか」「与信枠を超えていないか」のような業務ルールを守るのは、集約ドメインサービスの役目です。

状態を変えるのも同じです。アプリケーションサービスがエンティティのプロパティを外から書き換えるのではなく、集約のメソッドを呼んで集約自身に変えてもらいます

だから業務ルールの条件式はここに書きません。判断も状態変更も、Domain層に任せます。

取り出し・保存・記録はInfrastructure層に任せる

アプリケーションサービスがInfrastructure層に頼むのは3つです。

リポジトリで集約を取り出す、ユニットオブワークで変更を保存する、ロガーでログを残す。DBへの接続もSQLの組み立ても、Infrastructure側の仕事です。

頼むときに使うのは、リポジトリ・ユニットオブワーク・ロガーという契約(インターフェース)だけです。

実装クラスの名前は1つも出てきません。上の図でこの線だけ点線になっているのは、そのためです。

契約を実装するのはInfrastructure層の側なので、依存の向きは逆転しています(第15回第18回)。実装がEF CoreでもDapperでも、このコードは変わりません。

プロ太

なので、DbContextやSQLの記述はここに書きません。永続化の技術はInfrastructure層に閉じ込めます。

状態を変えるユースケースは同じ形をしている

ここから中身に入ります。状態を変えるユースケースは、どれもだいたい同じ形をしています。

アプリケーションサービスの七つの手順

七つの手順は、次の役割で分かれています。

  • (1) (2)入口。要求を受け取り、通してよいかを決める
  • (3)材料をそろえる。判断と変更に必要な集約を取り出す
  • (4) (5)業務ロジック。判断も状態変更もDomain層が行う(図の青い帯)
  • (6)書き戻す。変更をまとめて一度だけ保存する
  • (7)記録する。成功も失敗もログに残す
  • [作業単位](3) から (6) を囲む範囲。前回のユニットオブワークがこれにあたる

同じ形をコードにすると次のようになります。「注文を確定する」という題材で、構造だけC#、処理は日本語で書いた疑似コードです。

// 「注文を確定する」ユースケース(形を示すための疑似コード。実在のコードではありません)
注文を確定する(注文内容, 操作ユーザー)                          // (1) 要求を受け取る
{
    try                                                        // 失敗しても (7) へ
    {
        if (操作ユーザー が購買担当でない)                       // (2) 権限を確かめる
            権限エラー("発注する権限がありません");

        using (var 作業単位 = 作業単位を開く())                  // [作業単位]ここから
        {
            商品   = 作業単位.商品を取り出す(注文内容.商品ID);     // (3) 取り出す
            購入者 = 作業単位.ユーザーを取り出す(操作ユーザー.ID);

            発注ルール.確認する(商品, 購入者, 注文内容.数量);      // (4) 判断させる

            商品.在庫を減らす(注文内容.数量);                     // (5) 変更させる
            注文 = new 注文(注文内容.商品ID, 注文内容.数量);

            作業単位.注文を追加する(注文);                        // (6) 保存する
            作業単位.保存する();
        }                                                       // [作業単位]ここまで

        ログ.成功("注文しました", 注文.ID);                       // (7) ログを残す(成功)
        return 注文;
    }
    catch (エラー e)
    {
        ログ.失敗(e, "注文に失敗しました");                        // (7) ログを残す(失敗)
        throw;
    }
}

成功/失敗時にログ出力をしたり、失敗時に例外を適切にハンドリングすることも、Application層ユースケースの大事の仕事です。

(1) 要求を受け取り、(2) 権限を確かめる

ユースケース1件を1つのpublicメソッドにすると、画面からもバッチからも同じ入口を使えます。画面を起動しなくても、この単位で動かして確かめられるようにもなります。

続く (2) で悩みやすいのが、認可はDomain層とApplication層のどちらの仕事かという点です。これは、守っている対象が違う、と考えると整理できます。

  • Domain層が守るのは「データとしてありえない状態にしない」こと(不変条件)
  • Application層が決めるのは「この操作を、この人がしてよいか」

後者はアプリケーションの都合です。集約に持たせると、業務ルールとは関係のない「いま操作しているのが誰か」という情報を、集約が抱えることになります。

ロールという区分そのものはDomain層の概念ですが、いま誰が操作しているかはアプリケーションの文脈です。

ただし判定に業務知識が要る場合は、判定だけをDomain層に問い、拒否するかどうかはApplication層で決めます

例えば「自分が出した注文しかキャンセルできない」というルールなら、注文を取り出してみないと、それが自分のものか分かりません

誰の注文かを知っているのは注文の側なので、問い合わせた結果を受けてApplication層が拒否します。

プロ太

認証や認可の仕組みそのもの(Claimsやポリシー)については、次のPart VIで扱います。

(3) 取り出し、(4) 判断させ、(5) 変更させる

「判断と状態変更を任せる」という矢印を、1段だけ拡大してみます。

取り出すのはApplication層、判断と変更はDomain層

疑似コードでいうと、次の5行がこの往復にあたります。

商品   = 作業単位.商品を取り出す(注文内容.商品ID);     // (3) 取り出す
購入者 = 作業単位.ユーザーを取り出す(操作ユーザー.ID);

発注ルール.確認する(商品, 購入者, 注文内容.数量);      // (4) 判断させる

商品.在庫を減らす(注文内容.数量);                     // (5) 変更させる
注文 = new 注文(注文内容.商品ID, 注文内容.数量);

(3) は、判断と変更に必要なものを取り出すだけです。ここで業務ルールは一切みません。

(4) は、取り出したものをそのまま 発注ルール.確認する へ渡します。

(5) で状態を変えるのは集約自身です。

(3),(4),(5)はどれもアプリケーションサービスが自分では作業せず、別の層へ任せているという点は共通しています。

[作業単位]を開き、(6) 保存する

[作業単位] は、1回のユースケースで変えたものを、まとめて保存するための範囲です。1メソッドにつき1作業単位、保存は1回だけになります。

途中で例外が出れば (6) に到達しないので、中途半端な状態が残りません。「在庫だけ減って注文が残らない」という事故が起きないのは、この形のおかげです。

ユニットオブワークそのものの仕組みと、この範囲がDBのトランザクションとどう重なるかは前回扱いました。

今回みているのは、その範囲をどこに引くかを決めるのがApplication層の仕事だという点です。

(7) ログを残し、例外を伝える

(7) はコード上の2か所、成功時と失敗時に現れます。どちらに転んでも最後に必ず通る手順という位置づけです。

失敗はどの手順でも起きます。だから try / catch でメソッド全体を囲み、すべての失敗を (7) へ集めます。

ログをApplication層に集めるのは、ユースケース単位が「何が失敗したか」を最も正確に言える単位だからです。

リポジトリの中で記録しても「取得に失敗した」までしか分かりません。

ただしどこでログを残し、例外をどう伝えるかに唯一の正解はありません。層ごとに流儀がばらつくと、同じ失敗が何度も記録されたり、逆にどこにも残らなかったりします。

プロ太

プロジェクトの方針として先に決めておくことが大事ですね。

例外の伝え方だけでも、次のような選択肢があります。

  • そのまま再スローする(呼び出し側が受け止める)
  • アプリ固有の例外型に包み直す(利用者に伝える理由を型で表す)
  • Result型で戻り値にする(失敗を例外ではなく値として扱う)

MentorAppがどれを選んでいるかは、後半の実装で確かめます。

なおこれは典型的な形で、ユースケースによっては手順が前後したり、判断が要らずに (4) がなかったりします。

毎回七つ書く型ではなく、漏れがないかを見るための一覧だと思ってください。

MentorAppの実装をみる

ここからは実物のC#コードです。MentorAppのApplication層は次の構成になっています。

src/MentorApp.Application/
├─ Mentorships/MentorshipService.cs        ← 今回、具体例としてピックアップ
├─ Topics/TopicService.cs
├─ Users/UserService.cs
├─ Users/UserAuthenticationService.cs
└─ Contracts/                                 … 契約(インターフェース)
    └─ Queries/                               … 読み取り側(第32回)

Contracts/Queries/ は一覧取得などの読み取り側で、契約だけがここにあり、実装はInfrastructure層にあります。次回以降で扱います。

残る4つが実装クラスで、すべて状態を変える側のサービスです。

MentorshipServiceCreateMentorshipAsync を例として、コードを個別に読む前に、まず七つの手順を重ねてみます。

CreateMentorshipAsync に重なる七つの手順
プロ美

前半でみた七つの手順と、まったく同じ並びだね!疑似コードで見た形が、そのまま実物に出てくるんだ。

依存関係と入口

クラス宣言をみると、受け取っている依存が4つあります。どれもWeb層の起動時にDIコンテナで配線され、注入されるものです。

public class MentorshipService(
    IUnitOfWorkFactory unitOfWorkFactory,                       // [作業単位]のため
    MentorshipDuplicationCheckService duplicationCheckService,  // (4) のため
    TimeProvider timeProvider,                                  // (5) で使う時刻
    ILogger<MentorshipService> logger)                          // (7) のため
{
    // ...
}

依存の一覧が、そのまま手順の一覧になっています。しかも4つとも契約であり、実装クラスの名前は1つも出てきません。

最初の図でInfrastructure層への線だけ点線にしたのは、この状態を指しています。

TimeProvider で時刻を外から受け取っているのは、テストで時刻を固定できるようにするためです。

メソッドの入口はこうなっています。

public record CreateMentorshipRequest(Guid MentorUserId, Guid MenteeUserId);

// ...

public async Task<Mentorship> CreateMentorshipAsync(
    CreateMentorshipRequest request,        // (1) 何をするか
    CurrentUser currentUser,                // (1) 誰がするか
    CancellationToken cancellationToken = default)

「何をするか」を record にまとめ、「誰がするか」を CurrentUser で受け取る形です。

(2) から (5) までを読む

メソッドの中身をみていきます。まず (2)[作業単位] です。

// (2) 権限を確かめる
if (currentUser.Role != Role.Admin)
    throw new UnauthorizedAccessException("メンタリングを作成できるのは管理者のみです。");

// [作業単位]ここから
await using var uow = await unitOfWorkFactory.CreateAsync(cancellationToken);

権限がなければ、作業単位を開く前に弾いています

続いて (3) から (5) です。

// (3) 取り出す
var mentor = await uow.Users.FindByIdAsync(request.MentorUserId, cancellationToken)
    ?? throw new ArgumentException($"メンターユーザーが見つかりません: {request.MentorUserId}");

var mentee = await uow.Users.FindByIdAsync(request.MenteeUserId, cancellationToken)
    ?? throw new ArgumentException($"メンティーユーザーが見つかりません: {request.MenteeUserId}");

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

// (4) 判断させる
duplicationCheckService.ValidateMentorshipCreation(
    mentor,
    mentee,
    hasActiveMentorshipForPair);

// (5) 変更させる
var now = timeProvider.GetUtcNow();
var mentorship = new Mentorship(request.MentorUserId, request.MenteeUserId, now);

ドメインサービス(MentorshipDuplicationCheckService)に渡しているのは mentormenteehasActiveMentorshipForPair の3つだけです。

リポジトリもDBも渡していません。Domain層がリポジトリを呼ばないという関係が、実装でも守られています。

(6) と (7) を読む

残りは (6)(7) です。

            // (6) 保存する
            await uow.Mentorships.AddAsync(mentorship, cancellationToken);
            await uow.SaveChangesAsync(cancellationToken);

            // (7) ログを残す(成功)
            logger.LogInformation(
                "メンタリング関係を作成しました: MentorshipId={MentorshipId}, MentorUserId={MentorUserId}, MenteeUserId={MenteeUserId}",
                mentorship.Id, request.MentorUserId, request.MenteeUserId);

            return mentorship;
        }
        catch (Exception ex)
        {
            // (7) ログを残す(失敗)
            logger.LogError(ex, "メンタリング関係の作成に失敗しました: MentorUserId={MentorUserId}, MenteeUserId={MenteeUserId}",
                request.MentorUserId, request.MenteeUserId);
            throw;
        }

(6)は前回のユニットオブワークの仕組みで学んだ通り、変更追跡した内容をここで一気にDBへ反映しています。

(7)で{MentorshipId} という名前付きプレースホルダーを使用しているのは、文字列に埋め込みではなく検索できる値としてログへ残すためです。後から特定のIDで絞り込めます。

例外の伝え方は、前半で紹介した3つの選択肢のうちそのまま再スローです。

投げる例外の型も、専用のものは作らず UnauthorizedAccessExceptionKeyNotFoundException といった標準例外をそのまま使っています。

プロ太

「利用者に具体的な理由を伝える必要があるときだけカスタム例外を定義する」という方針です。

層ごとの分担も含めて、MentorAppでは次のように決めています。

失敗したときにすること
Domain層例外を投げるだけ。ログは書かない
Application層構造化ログに記録し、そのまま再スローする
Web層ログは書かず、利用者向けのメッセージを表示する

この方針は development-guide.md に文書として残されています。決めて書いておくことそのものが、ばらつきを防ぐ仕組みになります。

MentorAppにおいて、状態変更を行うユースケースのメソッド(コマンド側に相当するもの)は、全てこれと同じような形で書かれています。

呼び出し側からみた境界

最後に、画面側に何が残ったのかを確かめます。

ここまで読んできた CreateMentorshipAsync を呼び出している、MentorshipAddForm.razor の追加ボタンのハンドラです。

private async Task AddAsync()
{
    if (!editContext.Validate()) return;

    var currentUser = await AuthService.GetCurrentUserAsync();
    if (currentUser is null) return;

    isProcessing = true;

    try
    {
        var request = new CreateMentorshipRequest(form.MentorUserId!.Value, form.MenteeUserId!.Value);
        await MentorshipService.CreateMentorshipAsync(request, currentUser);
        ToastService.ShowSuccess("メンタリング関係を追加しました。");

        await OnAdded.InvokeAsync();
    }
    catch (Exception)
    {
        ToastService.ShowError("追加に失敗しました");
    }
    finally
    {
        isProcessing = false;
    }
}

画面に残っているのは、入力の検証・操作ユーザーの取得・要求の組み立て・結果の表示・一覧の更新・ボタンの状態管理です。

ユースケースの呼び出しは await MentorshipService.CreateMentorshipAsync(request, currentUser) の1行だけです。

ここにも try / catch がありますが、目的が違います。Application層の (7) はログを残すため、Web層は利用者にメッセージを見せるためです。

だからWeb層では ILogger を使わず、内部のエラー情報も画面に出しません。

まとめ

今回は、状態を変えるユースケースを1つのメソッドにまとめ、そこに何を書き、何を書かないのかを整理しました。重要なポイントは以下です。

  • アプリケーションサービスはユースケースの進行を受け持つ
  • 七つの手順は (1)要求を受け取る →(2)権限を確かめる →(3)取り出す →(4)判断させる →(5)変更させる →(6)保存する →(7)ログを残す
  • 認可・取り出し・保存・ログ記録は自分の仕事、判断と状態変更はDomain層へ委任(一部は契約経由でInfrastructure層へ委任)

今回はCQRSにおけるコマンド側のユースケースをみてきました。

次回は、そのCQRSそのものを扱います。なぜ読み書きを分けるのか、分けると何が変わるのかを整理し、その次の回でクエリ側の実装をみていきます。

プロ太

アプリケーションサービスは、1つのユースケースを実行する司令塔・オーケストレーションを担うということがわかりましたね。

引き続き、実務的なWebアプリの開発について一緒に学んでいきましょう!

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