370
312

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

ドメイン駆動設計#1Advent Calendar 2019

Day 10

Laravelでドメイン駆動設計(DDD)を実践し、Eloquent Model依存の設計から脱却する

Last updated at Posted at 2019-12-09

この記事はドメイン駆動設計#1 Advent Calendar 2019の 10 日目の記事です。

2020/12/17追記

以下に続編を書きました!
LaravelにDDDを導入して1年経った所感(達成したこと / 課題点 / モデリングの難しさなど)

やったこと

自社サイトのバックエンドを Laravel で実装して半年間が経ち、初期に考えた設計にいろいろと綻びが出てきたと感じていました。
そんな中、ちょうど実践ドメイン駆動設計や Web+DB Press で特集された体験 DDD を読むことができたので、さっそくいくつかの機能を DDD で実装してみました。
本記事では「もともと Laravel で実践していたEloquent Model 依存の設計」の問題点を提起し、「DDD を取り入れて実装した結果」のソースコードや考え方、そのメリットを記載しています。

結論

  • Laravel の Model をあらゆるレイヤーで使うと改修が難しくなる
  • 開発する機能のユースケースを主語と述語で文章に表現し、そのまま UseCase 層の実装として表現する
  • Entity や ValueObject に制約条件をまとめ、適切に例外を吐く
  • Laravel の Model は ORM としてのみ利用する
  • PHP の言語自体の限界はあるので、命名の工夫などで適宜我慢する
  • 実運用の際はどの機能から、どこまで完璧主義で DDD をやるか考える

初期の設計

まずは「もともと Laravel で実践していたEloquent Model 依存の設計」についてお話します。

本記事で話題に上げるのは、私が勉強 Q&A サイトを開発していたときの話です。開発する上で意識すべきデータは「質問者・回答者」「質問」「回答」などです。

Laravel の設計は、おおまかに下記の方針で行いました。

  • テーブルごとに Laravel のModelを作成する(User、Question、Answer・・・)
  • それぞれの Model を使ってデータを CRUD するRepositoryを作成する(UserRepository、QuestionRepository、AnswerRepository・・・)
  • Repository からデータを取ってきたあと、サービスの仕様に合わせて整形する目的で、Serviceを作成する(UserService、QuestionService、AnswerService・・・)
  • 同様にControllerを作成する(UserController、QuestionController、AnswerController・・・)

このように、あくまでテーブル構成を思い浮かべ、テーブル構成に対応した Model を作成し、以降 Repository、Service、Controller と、レイヤー化しているように見えるけど、実際はただ単にデータをリレーしているだけのアーキテクチャを組んでいました。

Model の扱い

例えば「質問を 1 件取得する」場合、下記の手順で実装します。

① QuestionRepository に返り値としてQuestionModel を返す質問取得メソッドを実装

public function findQuestion(int $id): Question
{
    return Question::findOrFail($id);
}

② 次に QuestionService にfindQuestion(int $id)を QuestionRepository のfindQuestionを読んで返すだけの内容で実装します。

public function findQuestion(int $id): Question
{
    return $this->repository->findQuestion($id);
}

③ 最後に QuestionController を実装します。

public function findQuestion(int $id)
{
    return view('question.find', [
        'question' => $this->service->findQuestion($id)
    ]);
}

④ blade ファイルの中では$question を起点にデータをリレーションで取得して表示します。

<h1 class="title-question">
  {{ $question->title }}
</h1>
<span class="author-name">{{ $question->user->name }}</span>
<span>さんの質問</span>
@foreach ($answers as $question->answer)
<section class="answer-item">
  ... 以降、質問の各回答が並ぶ ...
</section>

このように、**【Eloquent Model を View まで返す】**方法で実装しました。

Model を View まで取り回すメリット

何にせよ開発が非常に速いです。

{{ $question->user->name }}というのは、Question.php に

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class)->withDefault([
            // ...
        ]);
    }

のようにリレーションが設定されていれば、View 層で User が Question のプロパティかのように繋げてアクセスできるということを示しています。

User にリレーションが設定されていれば、さらにそこから繋げてデータにアクセスできます。

これはつまり、**【View 層で新しいデータが欲しいときは、Model だけ変更すれば Repository、Service、Controller の変更が不要である】**ことを示します。
もちろん Repository や Service の変更を伴う改修も多いですが、新しいデータを View に出すという要件に対しては、何も考えなければ、Question モデルから順番にリレーションをたどってほとんどのデータにアクセスできるため改修スピードを速めることが可能です。

例えばあるとき、「質問一覧画面には質問本文の最初の 20 文字だけ表示して欲しい」という要件があったとしましょう。下記のように blade ファイルに直接 PHP のプログラムを書くのはちょっと憚られますよね(※str_limit_ja は全角文字ベースで文字列をトリミングする関数とします)。

{{ str_limit_ja($question->body, 20) }}

このとき、Repository や Service を変更しなくても、Question.php に Accessor を増やせば実装が終わります。

Question.php
    public function getShortBodyAttribute($value): string
    {
        return str_limit_ja($this->body, 20);
    }

このように Accessor を書けば、View では

{{ $question->short_body }}

で 20 文字にトリミングされた質問文が表示できます。

この魅力にハマった我々は、次から次へと改修案件をこの方法で捌いていきました。
我々も「View にロジックを載せるのは悪手」ということくらいは知っていたので、**View にロジックを避けつつ Question に関する処理をまとめて書ける Accessor は優秀だ!**と考えました。
弊社はスタートアップということも有り、次から次へと副業のエンジニアさんも入ってきました。入ってきて既存実装を見て、「なるほど Model に Accessor を生やすのか」と真似していきました。こうして Eloquent Model はあらゆるページに、様々なリレーションと Accessor を伴って広まっていったのです。

後々やってくる苦難の道など窺い知る余地もなく・・・

発生しうる問題

このようにテーブルを起点にクラス設計を固めてしまったある日、こんな要件が発生したとします。

【質問一覧では 20 文字まで表示だけど、ユーザーのプロフィールで見れるユーザーの質問一覧には質問本文を 40 文字まで表示してね】

いつもどおり Accessor を増やそうとすると、すでにこんな実装が・・・

Question.php
    public function getShortBodyAttribute($value): string
    {
        return str_limit_ja($this->body, 20);
    }

こうなると、諦めて blade にstr_limit_jaを実装するか、medium_bodyのようなヤバいネーミングの Accessor を増やすか・・・みたいな選択肢になってきますね。

逆もまた然りです。

【こないだの質問一覧の 20 文字さ、ちょっと短すぎるから一覧 → 詳細の遷移率上げるために 30 文字に増やしてよ】

よしきた、と変更したところ

Question.php
    public function getShortBodyAttribute($value): string
    {
+        return str_limit_ja($this->body, 30);
-        return str_limit_ja($this->body, 20);
    }

なんと、全く関係なかったはずのユーザーのプロフィールで見れる質問一覧の文字数も 30 文字に増えてしまいました

いつの間にか誰かが profile.blade.php でも$question->short_bodyを使っていたようです。

※以上の内容はフィクションですが、実際に似たような事案が何度も発生しました。

なぜこのような問題が起こったのか?

一言で言えば、

【質問データがユーザーからどう見えるかは、ユースケースによって異なるはずなのに、全てのケースにおいて同じ Class のオブジェクトを返して実現していたから】

だと考えています。

  • 質問個別ページで見る質問
  • 質問一覧ページで見る質問
  • プロフィールページで見る質問
  • 質問投稿者自身が質問個別ページで見る質問
  • ログアウトユーザーが見る質問、ログインユーザーが見る質問
  • 質問を投稿するときに入力する質問

といった、サービス内でも様々なユースケースに応じて姿を変える「質問」を、そもそも全て単一の「Question.php」のインスタンスで実現しようとしたことに無理があったはずです。

これは User でも近いことが発生してしまっていました。開発していた勉強 Q&A サイトでは、現役の家庭教師や塾講師が学生ユーザーの質問に回答します。それぞれのユーザーはメールアドレスやパスワードなどの認証情報を同じusersテーブルに所有していたことからUser.phpで Model を作成し、取り回していました。

これにより、たとえば生徒しか使えない機能を実現する場合でも、理論上全てのタイプのユーザーが入りうる$userが blade にやってくるから余分に if 文を書かないといけない。といった問題が起こっていました。

補足

Laravel を API サーバーメインとして活用する場合、API リソース(https://readouble.com/laravel/6.x/ja/eloquent-resources.html) という機能を活用すれば、API から返る値を仕様に応じたパラメータに整形する役目を担うレイヤーを作成できます。

開発していた勉強 Q&A サイト でも Nuxt で SPA として開発を始めたときや、iOS アプリを開発したときに API を組みましたが、そのときは API リソースを使うことで、リレーションが盛り込まれた Model を最小限にしてクライアントに返すように実装できました。対処療法ではありますが、API リソースによって Model の取り回しによる問題のいくつかは解消できますので、個人的には Laravel の機能で一番好きな機能です。

補足2

一方、ルートモデルバインディング(https://readouble.com/laravel/6.x/ja/routing.html) は Laravel の中でも指折りのヤバい機能だと思っています。これは Controller で直接 Action Method の引数に Model インスタンスを受け取れるというものです。

Controller が Model をいきなり扱えるためレイヤードアーキテクチャの根本から覆してしまいますし、直接 Model を受け取るために、いわゆる N+1 問題を解消するための eager load を行うためのwithメソッドを噛ますことができません。

しかし、これも開発時間短縮を実現できるため一時期弊社内で流行し、かなり多用された結果、Question.phpモデル自身にwithプロパティが設定され、質問に関連する全クエリに強制的にwithが走るという状況が発生してしまいました。ヤバいです。

設計の問題点を受けての考察

ここまで考えて、当時の僕が考えたのは下記のようなことでした。

  • Repository→Service の時点で、Laravel の Eloquent Model を返さず、用途に応じた独自のオブジェクトを返すべきなのではないか?
  • そのオブジェクトはシンプルな PHP の Class(いわゆる POPO)として実装し、プロパティへの型補完が効くように実装すれば透明性も担保できる
  • Repository の役目は Eloquent Model→POPO への変換に特化させる
  • Service〜View が扱うオブジェクトは独自オブジェクトだけになるから、影響範囲を絞り込める

このとき僕が考えた【Repository が返してくる独自のオブジェクト】が、DDD の文脈で言うところの【Entity】に落ち着いてくると思うのですが、当時の僕はそこまで考えられていませんでした。

ただ、具体的な実装方法が固まりきらなかったためこの考察は考察しただけで終わり、また Model を流用しまくる日々に戻りました。

転機

そんな日々に転機が訪れ、DDD 実践へ繋がっていった転機が 2 つ有りました。

転機 ① スマートフォンアプリ開発

1 つ目はネイティブアプリの開発です。

Web と同等以上の機能を有する iOS アプリをリリースするにあたって、1 人でアプリで利用する API を全て組みました。初めてスマホアプリ開発に関わって気がついたことは色々ありすぎるので別途記事にまとめられたらと思うのですが、DDD 関連で気がついたことといえば、やはり影響範囲についてでした。

ネイティブアプリは Web と違って、リリースするとユーザーがアップデートしない限りこちらから関与することは原則できません(強制アップデートを除く)。

今まで Web だけで考えていた影響範囲がアプリにも広がり、さらにアプリもバージョンごとに考えないといけないことを考えると、それら全てのデバイスから質問データにアクセスするときは Question.php を通っているという事実が、恐ろしく思えてきました。

さきほどのshort_bodyのような独自 Accessor をアプリ向けの API でも利用しようものなら、もう改修が怖くなって改善スピードが低下する未来が想像できました。

結果、Postman を使って API テストを組むことでお茶を濁したのですが、テストは出口対策なので、設計レベルで改善できることはないかな、と考える時間が増えました。

転機 ②   Web+DB Press の特集【体験 DDD】

まあもう今回の記事はこの特集に関して本当にありがとうございました勉強になりましたって言いたいだけの記事と言っても過言ではないのですが笑

このころいわゆる Eric 本を買って読んだものの何一つわからず手元で Entity っぽものを組んで、いや違う、こんなの実戦投入できないと頭を悩ませていた中、この特集の話を聞いて速攻買いました。

実際に UseCase 層、永続化層、Domain 層の解説とともに(Java ではありますが)生のソースコードが添付されており、非常にイメージしやすい内容になっていました。やはりエンジニアはソースコードで会話するのが一番です。

Laravel で DDD を実践する

お待たせしました。実際に実務上の施策で DDD を取り入れた設計・実装をやってみた内容をまとめていきます。

DDD を実践したときの手順

  1. 実現する機能に登場する人物、扱われるデータを UML に落とし込み、関係性やそれぞれの成立条件を可視化する
  2. 機能で実現する UX を「◯◯ が ■■ する」といった主語と述語で表現される文章にまとめる
  3. UseCaseを実装。Entity と Repository はモックで、あくまで 2. で作成した文章を実現するように実装する
  4. Entityの中にデータの初期化や変更を実装。
  5. UseCaseとのインターフェース用に ValueObject を実装
  6. 最後にRepositoryを作成するためにテーブル構成を考え、Laravel の Model を作成し ORM として永続化・検索処理を実装

1. UML 作成

まずは実現する機能に登場する人物、扱われるデータを UML に落とし込みます。

書くときは**PlantUML**という、YAML ファイルで UML を記載できるツールを使って書きました。

具体的には、VSCode に PlantUML のプラグインを入れて UML を記述していきます。公式ドキュメントを読めば書き方はすぐにわかります。

UML といっても色々あると思いますが、僕はクラス図を使って書いています(もちろん、この時点で、このクラス設計で実装しよう、というものを決定できるわけはないので、ここでクラスとして表現したものに設計が引っ張られないように注意します)。

iOS の画像 (3).png

雑にスクリーンショットを貼りましたが、このように YAML ファイルを左に、UML のプレビューが右に出た状態で編集でき、最後に PNG などでエクスポートできます。

業界によっては UML を書いてから実装なんて当たり前かもしれませんが、スタートアップで働いていて正直そういった仕様を明記しないことに甘えていたので、久々に UML を書きました。

詳しい書き方は「体験 DDD」に書いてありますが、個人的には、各クラスから吹き出しを生やして、制約条件を明記していくところがポイントです。そこで記述した制約条件をできる限り後述するEntity、または ValueObject に徹底的に閉じ込めていくことが重要です。

2. 「◯◯ が ■■ する」といった文章にまとめる

次に、実現したい機能についてユースケース図を書くか、または「◯◯ が ■■ する」という文章を箇条書きでまとめます。

例えば質問投稿機能を作成するとしましょう。

質問投稿機能を作るとき、エンジニアであれば

【本文やタグといった質問データを受け取り、現在のログインユーザー ID777 とともにデータベースに Insert する】

と考えるでしょう。しかし、「◯◯ が ■■ する」の形式で考えれば

【学生ユーザーが質問を投稿する】

と記載できます(※学生が質問する勉強質問サイトの場合)。

後述する UseCase を実装するときに、個人的な考えですが、「◯◯ が ■■ する」の文型で書くように実装することが重要だと思っています。

経験上、前述の【データベースに Insert する】という思考で実装すると、Service 層(以下、UseCase 層)のところまでデータベースの構成を意識した実装が漏れ出てきます。データベースのことを UseCase 層より上位のレイヤーが忘れて実装できるように、あくまで現実世界に即した、極論を言えばエンジニア以外の人にも通じるような表現に、実装する機能を落とし込んで記述できることが大事です。

補足

ここで主語と述語で文章表現することを徹底しなかった僕の失敗談があります。
定額課金機能を実装したときに、決済ベンダーで Pay.jp を利用させていただいているのですが、【Pay.jp 上に課金履歴を保存する】と考えながら実装したため、アプリ経由の課金(In App Payment)やキャリア決済の実装等を考慮した段階で、本来抽象化されているはずの UseCase 層が再利用しにくくなっていることに気が付きました。本当は【ユーザーが課金する】と考えながら実装することで、ユーザー Entity や課金履歴 Entity、または独自の Helper などに Pay.jp 独自のロジックを閉じ込める工夫ができたはずです。

3. UseCase を実装

ここまで考えたあと、実装を始めます。最初の実装を Entity などからはじめる方もいるかもしれませんが、僕はいまのところ UseCase から作り始めるのが好きです。

UseCase 層は、ユーザーが自社のサービスを利用する場面を表現する層です。

さきほど 2. で落とし込んだ「◯◯ が ■■ する」という粒度の情報を持っている層になります。

では、さきほどから例示している【学生ユーザーが質問を投稿する】UseCase を実装してみた例を示します。

QuestionPostUseCase.php
<?php

namespace App\QuestionPost\UseCase;

use App\QuestionPost\Domain\ValueObject\UserAccountId;
use App\QuestionPost\Domain\ValueObject\QuestionBody;
use App\QuestionPost\Domain\ValueObject\QuestionTags;

use App\QuestionPost\Domain\Repository\QuestionerRepositoryInterface;
use App\QuestionPost\Domain\Repository\QuestionRepositoryInterface;

use App\QuestionPost\Domain\Entity\QuestionEntity;
use App\QuestionPost\Domain\Entity\QuestioningUserEntity;
use App\QuestionPost\Domain\Exception\QuestionPostFailedException;

final class QuestionPostUseCase
{
    private $questionerRepository;
    private $questionRepository;

    // ※ここはLaravelのAppServiceProviderでRepositoryの実体をDIします
    public function __construct(
        QuestionerRepositoryInterface $questionerRepository,
        QuestionRepositoryInterface $questionRepository
    ) {
        $this->questionerRepository = $questionerRepository;
        $this->questionRepository = $questionRepository;
    }

    public function execute(
        // ポイント1 UseCaseの引数はValueObjectがGOOD
        UserAccountId $userId,
        QuestionBody $body,
        QuestionTags $tags
    ): QuestionEntity {
        // ポイント2 Repositoryから質問者Entityを取得
        // @var QuestioningUserEntity
        $questioner = $this->questionerRepository->getQuestioner($userId);

        // ポイント3 質問者Entityが質問を投稿
        // $question は QuestionEntityのインスタンス
        $question = $questioner->postQuestion($body, $tags);

        // ポイント4 永続化
        return $this->questionRepository->saveQuestion($question);
    }
}

ポイント1  UseCase の引数は ValueObject が GOOD

UseCase はおおむね Laravel でいうと Controller から呼ばれることが多いですが、その際の引数はValueObject(後述します)がおすすめです。

一番いけないと思うのは Array を渡すパターンです。$request->all()でリクエストの内容を取得し連想配列でレイヤーを超えてデータを渡していくことが僕は多かったのですが、内容が不透明になって、結局あとからRepository などで isset feat. 三項演算子地獄を生むことになります。
例えば int と型を定義すると、どんな数値であっても入ってくることができますが、UserAccountIdといった独自の型を定義すれば、より安全に、ヒューマンエラーを防いで扱うことができます。

ポイント2  Repository から質問者 Entity を取得

実現したい機能は【学生ユーザーが質問を投稿する】なので、まずは主語となる「学生ユーザー」を用意します。

「学生ユーザー」はすでに登録済みのユーザーなので、データベースに永続化されています。そのためRepository 経由でQuestioningUserEntity(1人の質問するユーザーを示すEntity)を取得します。

「学生ユーザー」なのでStudentEntityといった名称でも良いかもしれませんが、僕の見解としては、今後仕様変更で学生以外の種別のユーザーが質問可能になる可能性、などの幅を残すために「質問者 Entity」くらい抽象化した命名でもいいのではと考えています。

ポイント3 質問者 Entity が質問を投稿

※Entity のソースコードは後述します

次にQuestioningUserEntityに実装されている(この時点ではモックですが)postQuestionメソッドを実行することで質問を投稿します。

引数には質問作成に必要なValueObject(後述します)を受け取り、投稿に成功するとQuestionEntity(質問内容を示すEntity)を返します。

ポイントは、QuestionEntity型のインスタンスは、このQuestioningUserEntityに実装されたpostQuestion経由ではないと生成できないように実装することです。するとソースコード上で「質問は必ず QuestioningUserEntity に該当するユーザーが投稿する」ということを暗黙のうちに示すことができます。

このように Entity の生成ルートを縛ることで、今回の例だと、「user_id が NULL の質問データをテーブルに Insert してしまう」といった事故を防ぐことができますし、
Q&A サイトと一口にいっても掲示板のように匿名ユーザーでも書き込めるサイトもある中で、このサイトは必ずユーザーアカウントが存在する場合のみ質問できるのだ、ということをソースを読む人に伝えられます。

ポイント4 永続化

作成した質問 Entity は Repository によって永続化(=データベースへの保存)します。

Entity の作成と、永続化はわけるほうがお互いの責務が分離されて望ましいと思います。


以上で UseCase の解説を終わります。Entity や ValueObject の説明をしないで話を進めるのが辛くなってきたので先に進みますね。

4. Entity の実装

質問投稿機能の例では現在「質問者 Entity(QuestioningUserEntity)」と「質問内容 Entity(QuestionEntity)」が登場しています。

QuestioningUserEntityだけ、実装をざっくり例で示します。

QuestioningUserEntity.php
final class QuestioningUserEntity
{
    private $userAccountId;
    private $userType;

    // ポイント1 最重要!コンストラクタをプライベートにする
    private function __construct() {}

    // ポイント2 Repositoryが現在のデータを入れる静的メソッドを作る
    public static function reconstructFromRepository(
        UserAccountId $userAccountId,
        UserType $userType
    ): QuestioningUserEntity {
        // プライベートコンストラクタはクラス内からは呼べます。new self()等でも可
        $questioningUser = new QuestioningUserEntity();
        $questioningUser->userAccountId = $userAccountId;
        $questioningUser->userType = $userType;
        return $questioningUser; // 返すのはインスタンス
    }

    // ポイント3 質問投稿メソッド
    public function postQuestion(
        QuestionBody $body,
        QuestionTags $tags
    ): QuestionEntity {
        // ここで質問を作成できないユーザーの場合は例外をThrow
        if ($this->userType !== UserType::STUDENT) {
            throw QuestionPostFailedException::withMessages(
                [
                    'message' => '質問を作成する権限がありません'
                ]
            );
        }

        // QuestionEntityがインスタンス化されるルートがここだけになる→学生以外のユーザーは決して質問を作成できない
        $question = QuestionEntity::createByQuestioningUser(
            QuestionBody $body,
            QuestionTags $tags
        );
        return $question;
    }
}

ポイント1 コンストラクタをプライベートにする

まずはコンストラクタを明示的にプライベートにすることが大切です。インスタンスが作られる方法を特定のメソッドのみに絞ることで、絶対に不整合なデータや思わぬデータをデータベースから取得したり、保存できなくなります


こちらのコメントで頂いた通り、コンストラクタをこれまで通り Public にしていても、タイプヒンティングや適切な Validation を行っていれば問題ありません。個人的には UseCase からインスタンスを作るときと、データストアからデータを読み込んだときで生成ロジックを変えたいので、このような手法をとっています

ポイント2 静的メソッドでインスタンスを作成させる

プライベートコンストラクタになったら、どうやって外のクラスがインスタンスを作成するかと言うと、Public かつ Static なメソッドでインスタンスを作って返すようなものを作ります。

今回の例だとシンプルですが、プロパティの多いデータの場合は NULLable なデータはここでクラスメンバ変数に NULL を代入するなどします。

そうすることで、ソースコード上で、「このメンバ変数は NULLable ですよ」「このメンバ変数は必ず(ValueObject)型の値が入りますよ」ということが表現できます。

僕は今まで何度も、**「この連想配列のこのキーには何が入っているんだ?」**と関数の引数を見て困惑することがありましたが、こうやって生成元を縛ったクラスにしておけば、読み解くのが容易になります。

QuestioningUserEntityの場合は生成元が Repository、すなわちデータベースからのデータ読み取り時のみなので、reconstructFromRepositoryという命名にしています。僕の認識が正しければ PHP ではこのメソッドは Repository からしか呼べないといった制限を自然にはかけれないはずなので、仕方なく命名で担保しています。

ポイント3 他の Entity を生成するメソッドで制約条件を明記

(例外設計についてはまだ自分の中で正解が固まっていないので、このほうが良いと思いますといったご意見をお待ちしています!)

QuestioningUserEntityは、postQuestionメソッドで質問作成に失敗した場合、独自で設計した例外「QuestionPostFailedException」を Throw します。

ここでの失敗というのは、例えばQuestioningUserEntityに格納されているユーザーが「学生ユーザー」ではなかった場合や、他にも「1時間に1問しか質問できない」といったサービス独自の制約があったときにその制約に弾かれた場合などです。
こういった制約条件はpostQuestionメソッド内に集約されていて、UseCase からは条件の詳細を知ることは無いようにします。

QuestionPostFailedExceptionは Laravel で用意されている**ValidationExceptionを継承して実装するのがいまのところおすすめ**です。というのも、ValidationExceptionのサブクラスが Throw されると Laravel の例外ハンドラ(Handler.php)がステータスコード 422(API の場合)でクライアントに返してくれるからです。

例外が API のエラーメッセージやステータスコードを管理しているのが少し責務の位置づけが妙な気もしているのですが、Laravel を使ってきた自分としては自然なのでこの方法でやっています。

これらの制約条件をパスしたときのみQuestionEntityが同じような静的公開メソッドによってインスタンス化されて返り値となります。

質問作成時にのみ判断できる制約条件がある場合は、このcreateByQuestioningUser内部で実装するイメージです。

QuestionEntityの例示は省きます。

5. ValueObject(VO)の実装

ValueObject は、その名の通り値をオブジェクトとしてよりリッチに表現できる余地を残す仕組みです。

さっそく実装内容を示しますが、VO はいたってシンプルではあります。

UserAccountId.php
final class UserAccountId
{
    private $id;

    private function __construct()
    {
    }

    public static function create(int $primitiveId): UserAccountId
    {
        if ($primitiveId <= 0) {
            throw InvalidUserAccountException::withMessages([
                'message' => 'ユーザーIDが不正です'
            ]);
        }

        $instance = new UserAccountId();
        $instance->id = $primitiveId;
        return $instance;
    }

    public function toInt(): int
    {
        return $this->id;
    }
}

僕は Entity と同じ要領で、create メソッドのみからインスタンスを生成できるようにしていて、そこで int は int でも 0 以下の int は許さないよ、といった数値の Validation を噛ませているイメージです。

また、実際はデータベースの永続化等で int に直さないといけない場面もあるので、toIntメソッドを実装しています。

たったこの程度で実装が終わることが多いので、当初は実装する意味はないかなーと思っていたのですが、結局意義はあるなと思ったので、原則ほとんどの値に対して VO を作っています。

ValueObject の意義

ValueObject(VO)の意義は主に以下の 3 つあると思っています。

  • メールアドレスの形式検証のように、一般的な検証ロジックを集約する
  • 個々の値に対して成立する制約条件を表現する
  • 引数を VO にしてレイヤー間のデータをやり取りすることで、引数の順番をミスするなどのケアレスミスを防ぐ

検証ロジックに関しては、Laravel ならFormRequestを使ったほうが便利じゃないか、という意見があると思います。

しかし、FormRequest の場合、あのクラスの役目は「このリクエストにはどんなパラメータがあるのか、またはそれは必須か」という存在確認と、「それぞれのパラメータの値は【一般的に妥当か】」(メールアドレス検証など)と、「それぞれのパラメータはサービスを成立させる上で問題ないか」(メールアドレスが他のユーザーと被っていないか)というサービス仕様上の検証の 3 つほどの観点が混ざってしまっています。

具体的に何が問題かと言うと、弊社のように Web と iOS アプリにサービスを展開する場合、ほぼ似ているけど微妙に入力パラメータの違う API が複数生まれて、それぞれ FormRequest を作成していると、上記で言うところの**【サービス仕様上の検証】が複数のファイルにまたがって記述される**ことになります。

すなわち、ある日「ユーザーのメールアドレス重複を許す」という決定が例えば下ったとして(ヤバいけど)、そのときの変更範囲が各プラットフォームごとに発生するということです。それって大袈裟だなというか、Presentation 層に業務ロジックによる Validation が漏れ出ているから変更範囲が広がっているのだなと感じます。

なので、僕としては FormRequest は便利なのですが、あれだけで検証ロジック全て終わりではなくて、サービスの仕様に依存するものは ValueObject とか Entity で検証ロジックを表現しよう、と思うのです。メールアドレスの書式検証のような、ユースケースにあまり依存しないものは VO で、メールアドレスが既存のユーザーに被っているかどうか、というユースケースに寄ったものは Entity やドメインサービス(※ここでは書かないですが詳しくは体験 DDD や実践 DDD を参照)に書き込みます。

また、関数の引数を VO にすることで、連想配列の中身がわからないとか、引数が全部 int だからうっかり順番を間違えてしまう、というようなミスを防ぐことができます。

6. Repository の実装

ようやくここまで来て永続化の話ができるようになりました。最初の Laravel の Eloquent Model 依存の設計手法ではテーブル設計から考えていたので、ここまで進めてようやく Repository を考えるというのは、僕にとってはかなり斬新です。もちろんテーブル設計が複雑な場合、結局 Entity や UseCase が引っ張られる可能性はありますが、原則 UseCase や Entity から考えるのが良いと思います。

Repository がやることは至ってシンプルです。ここではMysqlQuestionerRepositoryの具体実装を示します。忘れた方は UseCase の説明に戻って、getQuestionerメソッドを使っている箇所を探してみてください。

MysqlQuestionerRepository.php
final class MysqlQuestionerRepository implements QuestionerRepositoryInterface
{
    public function getQuestioner(UserAccountId $userId): ?QuestioningUserEntity
    {
        $userOrm = new \App\Model\User();
        $userData = $userOrm->find($userId->toInt());

        if ($userData === null) {
            return null; // or throw an Exception
        }

        return QuestioningUserEntity::reconstructFromRepository(
            $userId,
            UserType::create($userData->user_type)
        );
    }
}

Repository の実装のポイントは、なんといってもLaravel の Eloquent Model を ORM でのみ利用するというところです。

        $userOrm = new \App\Model\User();
        $userData = $userOrm->find($userId->toInt());

ここで懐かしの User モデルが利用され、find メソッドによって指定した ID のユーザーインスタンスを取得します。

しかし最終的には先述のreconstructFromRepositoryによってQuestioningUserEntityに Wrap され、ID とユーザータイプだけを持ったインスタンスとしてユースケース層に渡っていくこととなります。

この方法によって、僕が頭を悩ませていた、Model がどこからでも使われていて影響範囲が読めない問題を防ぎます。ユースケースやドメイン知識ごとに適切な Entity と、その Entity に必要なデータだけ取得、保存する Repository を作成することで、もちろんファイル数やクラス数は爆増しますが影響範囲を絞ることに成功します。テスタビリティも向上することでしょう。


こちらのコメントで頂いたとおり、Repository の実装クラスの命名には、MysqlHogehogeRepository のように永続化しているデータストアの名前を入れるのがふさわしいです。というのも、例えば UseCase のテストコードを書く際に、テスト時にだけ Repository の実装クラスをインメモリの SQLite に永続化するクラス InMemoryHogehogeRepository に差し替えることができるからです。データストアの名前を入れないと、こういったRepositoryの差し替えをしたいときに紛らわしくなりますね。

補足

ここまでで一通りの説明は終了します。最後に補足をいくつか。

UseCase のクラス設計

僕は1 ユースケース 1 クラスで作るのが気に入っています。唯一のメソッドexecuteのみを有するイメージです。

なぜかというと、UserUseCaseのような抽象的な名前にしてしまうと、なんでもかんでもそのファイルに実装が詰め込まれ、可読性の低下、複雑にするだけの再利用といった結果を生むからです。

Interface について

レイヤ間の抽象化に Interface の実装は欠かせません。僕は現状、Repository にのみ Interface を作成し、実装するというルールにしています。UseCase も Interface を作ったほうが良いのかなとは思いますが、単純に手間なのでやっていません。

AppServiceProvider.php で Interface と実装を Bind させるように設定しています。

どこまで DDD するのか?

もちろんサービス全体を DDD で作り直すのが理想でしょうが、正直今の自分にその余裕はないです。弊社がスタートアップというのもありますが、技術都合で DDD に変更しなければならない!というのを押し通すのは難しいなと思っています。

とはいえ、現時点で弊社で DDD に挑戦しているのは「サイトに登録している家庭教師への指導依頼=コンバージョン」と、「学生ユーザーが限定機能を開放する定額課金プラン=実際に金銭が動く」というサイト内でもかなり高難度かつミスが許されない部分です。こういった特定の機能であれば、ある意味他機能から独立するのが望ましい上に、経営層へ実装に時間をかけ保守性およびテスタビリティを高める説明が自然にできるため、実践したという流れになります。

テストは書いているのか?

最初テストを書かなかったのですが、DDD で開発していると Entity や VO、UseCase の作り直しが開発の過程でしばしば発生するので、テストを書いていないと変更が億劫になりいずれ手抜き実装が爆誕することが想定されました。

現在は PHPUnit を使って、UseCase 単位のテストは書くようにしています。また、結合テストとして HTTP テストも記述しています。

詳しくは別の記事などで書ければと思いますが、Laravel ではTestCaseクラスが独自拡張されていて、setUpメソッドをオーバーライドして利用することで Eloquent Model や factory メソッドをテストケースで利用、再利用することができます。setUpメソッドをオーバーライドせずに使うと DI などの Laravel の初期ロード(正式名称なんですかね)が動かないのでテストが書きにくいです。

テストも書くとなると、尚更事業優先度が非常に高いところからチャレンジするのが向いているなと感じているところです。

ディレクトリ構成は?

下記のようなディレクトリ構成でやってみています。既存設計がもうそこそこの規模になっているので、ルートディレクトリごと分けてしまっています。

app/
├── Console
├── Constant
├── Domain // ここのディレクトリ配下はDDDのアプローチで設計・実装している
│   ├── { DomainName } // 扱う事業領域名
│   │   ├── Domain // ドメイン層
│   │   ├── Infrastructure // 永続化層
│   │   └── UseCase // ユースケース層(旧設計がServiceという単語を使っており、意識して分けるためにUseCaseとした)
│   ├── { DomainName }
│   ├── { DomainName }
│   ├── ...
│   ├── Base // DDD全体でベースとなるClass。将来的にはEntityやVOの基底クラスも作りたい
│   │   └── Exception // ValidationExceptionを拡張したclassを配置
   ...
├── Events
├── Exceptions
├── Helpers
...

まとめ

旧質問投稿 UseCase

DDD をやる前だったら、質問投稿時の UseCase(Service 層)はこんなシンプルな実装になるでしょう。

QuestionService.php
    public function postQuestion(array $params, int $userId)
    {
        $question = $this->questionRepository->storeQuestion($params, $userId);

        return $question;
    }

この実装に比べれば、これまで説明した実装は、ソースコードが仕様を説明し、適切な制約をかけているという観点で非常に情報量が多い実装になっていることがわかると思います。
具体的には投稿内容が array にまとめられているより VO になっているほうがわかりやすい、どんなユーザーが質問投稿できるか理解しやすいなどです。

結論

  • Laravel の Model をあらゆるレイヤーで使うと改修が難しくなる
  • 開発する機能のユースケースを主語と述語で文章に表現し、そのまま UseCase 層の実装として表現する
  • Entity や ValueObject に制約条件をまとめ、適切に例外を吐く
  • Laravel の Model は ORM としてのみ利用する!!
  • PHP の言語自体の限界はあるので、命名の工夫などで適宜我慢する
  • 実運用の際はどの機能から、どこまで完璧主義で DDD をやるか考える

以上です

思った以上に長文になりましたが、今の自分の DDD の実力はこんなところです。もっと上手に設計できるように経験を積んで、運用を経て痛い目に遭っていこうと思います!

しかしやっぱり型のある言語がいいですね。最近はフロントもバックエンドも TypeScript で組むのが良いんじゃないかと思えてきています(過激派)。

ぜひ Twitter でも繋がっていただけると嬉しいです。
https://twitter.com/Meijin_garden

求人告知!

また、私が CTO を務めている「オンライン家庭教師マナリンク」では、エンジニアを募集しております。

マナリンクでは、オンライン家庭教師の先生方のために、サイト上で自身のプロフィールを魅力的に発信できるようにしたり、オンライン指導専用アプリをリリースするなど、次々にプロダクトを開発しています。日々新しい技術を勉強して、試す機会を探している方にはうってつけな環境です。
事業の成長を優先させながら、ソースコードやアーキテクチャの品質にもこだわりたい方、ぜひ上記の私の Twitter に DM でご連絡をください!

370
312
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
370
312

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?