🥶

Laravel で Repository Pattern を採用を断念したときに考えたこと

2022/09/16に公開

ここ最近、CodeIgniterを触ることが機会があったんですけど、やっぱりLaravelはいいなぁと思います。

昔に開発したプロジェクトのエンハンス案件で触れたんですけど、作りにもよるところは多いものの、保守性・拡張性がLaravelは圧倒している気がしています。コンセプトが互いに違うので、比べられるようなものでも無いような気がしますが。

というわけで、柄にもなく、雑記です。

直近、表題のことがあったので、備忘的にログっておきます。

きっかけ

なんか Repository Pattern って良さそうじゃね?

とあるSPA(Single Page Application)サービスで利用するデータリソースを提供するためのAPIサービスを、SPAサービスとは独立した環境下で構築するプロジェクト。

発端となったのは以下のような経緯でした。

  1. 原則『1エンドポイント:1モデル:1リソース』としたいよね(切実)
  2. ( ^o^) モデル数(テーブル数)が多いんだ!
  3. ( ^o^) 操作一つで、必要なレスポンスが変わるんだ!
  4. ( ^o^) いっぱいコールしたくないから、EPは1つにまとめたいんだ!
  5. ▂▅▇█▓▒ ('ω') ▒▓█▇▅▂ ウワァァァァァァァ!
  6. 焦燥の海から、整備性抜群の設計パターン「Repository Pattern」を発見
  7. やるやん。

要求や要件の概要としては、

  • 検索が根幹となるサービス
  • いち画面上で表示するマスターデータを切り替えられる仕様を想定
  • 切替前提を承知で、検索条件指定 + フィルター + ソート が標準搭載
  • 解析用途等で、ページを認識できるURLを必要とするためページネーションも設けられるようにしたい
  • レスポンス速度向上のため、1エンドポイントでそれらを担わせたい

という、重たい要求内容です。

MVCをベースに考えると、モデルやレスポンスを取り回すコントローラーが肥大化する未来は、火を見るより明らか。
肥大化はバグの増殖や、ロジックのブラックボックス化 + 属人化の温床なので、どうにか責任分解点を見出して回避したい課題でした。前提として、自社オリジナルの設計パターンを採用するほどの壮大な状況でもないので「Repository Pattern」という先人の知恵にたどり着いた次第です。

余談

アーキテクチャー・パターンの名称を振りかざすのって日本くらいで、Googleとかでは、あまり言われないものなのだとか。
名称や語源にこだわる日本人の特性から根付いているものであるようで、本場では、意識せずオブジェクト指向で作り切っちゃっているから、名称を言われても通じないこともしばしば、とのこと。

https://youtu.be/A1aSOhsEh58

Repository Pattern やるやん。の理由

Repository Pattern の特徴としては、

  1. データリソースを定義する「エンティティ」層を設ける
  2. エンティティを使ったCRUDロジックを定義する「リポジトリー」層を設ける
  3. リポジトリーの前に「インターフェース」層を設けて、リポジトリー層のロジックを抽象化
  4. インターフェースは名の通り、リポジトリー層のロジックのインターフェースだけ定義するもの
  5. 『定義したインターフェースで、どのロジックを使うか』を指定するために、インターフェースとリポジトリーをバインドする定義をプロバイダーに行う
  6. ビジネスロジックからは、リポジトリーのインターフェースを呼び出してCRUDを実行する

こんな感じに理解しています。これによって、

  • どこに、何が書いてあるのかが整理されていてわかりやすいこと
  • ロジックを変更しても、影響範囲が特定されるので実装コストが軽減されること
  • 物理的に分割されているから「関係無いところを間違って消しちゃった」みたいなケアレスミスを防ぎやすいこと
  • プロバイダーの設定を変えるだけで容易にデータリソースを変えることができること(RDBMSからFirebaseに変更)
  • データリソースの切り替えが容易なため、テスト用DBに切り替えもできるためユニットテストもしやすいこと

というような恩恵を感じることができます。Repository Pattern においては、後半のデータリソースの恩恵が強いと思ってます。

サンプル

事務所所属のストリーマー名簿に人員を追加するCRUDを想定。

リポジトリー

リポジトリーにデータリソースを定義するエンティティを使ったロジックを定義します。ここでは Eloquent Model を使ってます。

namespace App\Repositories\Vspo;

use App\Interfaces\VspoRepositoryInterface;
use App\Models\Vspo;

class VspoRepository implements VspoRepositoryInterface
{
    /**
     * constructor
     *
     * @param Vspo $vspo
     */
    public function __construct(Vspo $vspo)
    {
        $this->vspo = $vspo;
    }
    
    /**
     * 新規登録
     *
     * @param array $request
     * @return int
     */
    public function store(array $request = []): int
    {
	$this->vspo->fill($request);
        return ($vspo->save())? 1: 0;
    }
}

インターフェース

リポジトリーのインターフェースを定義します。ビジネスロジック側では、インターフェースのメソッドを呼び出すことになります。

namespace App\Interfaces;

interface VspoRepositoryInterface
{
    public function store(array $request = []): int;
}

プロバイダー

リポジトリーとインターフェースの繋ぎ込み(バインド)をします。

namespace App\Providers;

use App\Interfaces\VspoRepositoryInterface;
use App\Repositories\VspoRepository;
use App\Repositories\VspoTestRepository;
use Illuminate\Support\ServiceProvider;

class RepositoryServiceProvider extends ServiceProvider
{
    /**
     * Register services.
     *
     * @return void
     */
    public function register()
    {
        $this->app->bind(
            VspoRepositoryInterface::class,
            VspoRepository::class
        );

	// // テスト用(実際はコメントアウトで切り替えるなどせず .env の設定とかで切り替えます)
        // $this->app->bind(
        //     VspoRepositoryInterface::class,
        //     VspoTestRepository::class
        // );
    }

    /**
     * Bootstrap services.
     *
     * @return void
     */
    public function boot()
    {
        //
    }
}

ビジネスロジック

インターフェースのメソッドを呼び出して、実際に処理したいことを実行します。

namespace App\Services;

use App\Interfaces\VspoRepositoryInterface;
use App\Services\StorageService;

class VspoService
{
    /**
     * constructor
     *
     * @param VspoRepositoryInterface $vspoRepositoryInterface
     * @param StorageService $storageService
     */
    public function __construct(
        VspoRepositoryInterface $vspoRepositoryInterface,
	StorageService $storageService
    ) {
        $this->vspoRepository = $vspoRepositoryInterface;
        $this->storageService = $storageService;
    }

    /**
     * ストリーマーの新規登録
     *
     * @param array $request
     * @return boolean
     */
    public function store(array $request = []): bool
    {
	$params = $request;
	$params['main_visual'] = $this->storageService->up($params['file']);
        return responce()->json(
	    [
	        'success' => $this->vspoRepository->store($params),
		'status' => 200,
	    ]
	);
    }
}

コントローラー

コントローラーはリクエストとレスポンスの橋渡しをするだけです。

namespace App\Http\Controllers;

use App\Http\Controllers\Controller;
use App\Http\Requests\VspoStoreRequest;
use App\Services\VspoService;
use Illuminate\Http\JsonResponse;

class VspoController extends Controller
{
    /**
     * constructor
     *
     * @param VspoService $vspoService
     */
    public function __construct(VspoService $vspoService)
    {
        $this->vspoService = $vspoService;
    }

    /**
     * store
     *
     * @param VspoStoreRequest $request
     * @return JsonResponse
     */
    public function store(VspoStoreRequest $request): JsonResponse
    {
        return $this->vspoService->store($request->validated());
    }
}

この編成であれば、互いの疎結合関係によって、結合・分離がしやすい状態になります。

例えば、バリデーションを調整したい、とか、モデルを追加したい、とかのときでも、ケースにあわせて調整する場所や範囲が限定されるし、各ロジックがシンプルな分量にまとめることができるので整備性が高いよね、と。
MVCでありがちな「Fat Controller」を避けることができるというわけですね。

断念した理由は Active Record パターンとの衝突

そもそもActive-Recordって?

Active-Recordパターンは、Enterprise Application Patternsの一種で、一つのデータベースのテーブルと一つのクラスを対応付け、またそのクラスのインスタンスを(クラスに対応する)テーブルの一つのレコードに紐付ける、というパターンです。よくRuby on RailsやLaravelなどのWebフレームワークで用いられます。参考: P of EAA: ActiveRecord
これは一種のO/Rマッパーで、オブジェクト指向のやり方で簡単にデータベース操作ができるようになります。

https://qiita.com/CostlierRain464/items/5be3c1860bb5137db3d1#active-recordを自前実装する方法

Laravelには強力な Active Record パターンのパッケージライブラリ「Eloquent ORM」があります。

1テーブル:1クラスの関係性を持ったロジックを担ってくれるデータベースクラスが豊富で、Laravelを採用するなら使い倒したい便利なやつです。前述のサンプルコードでも、Eloquent ORM モデルをエンティティ的に使う書き方をしています。

Repository Pattern を採用するにあたって調べていくと、この Eloquent ORM の存在が、Repository Pattern で得られる効果やそれそのものの役割・目的に対して被る範囲が大きく、所属するチームにおいて、わざわざ採用しても恩恵があまり得られないものだと判断して断念した次第でした。。

その理由は以下の通りです。

ユニットテストをしやすいはずが Eloquent ORM で隠蔽される部分が多く検証できないこと

Repository Pattern の特徴である疎結合関係により、テスト時と本番運用時でバインド方法を切り替えることができます。リポジトリー層をテスト用のクラスメソッドにバインドし直すことでテストメソッドや、テスト用のエンティティと結合された状態での安全でライトに動作検証を実施することができます。

また、エンティティレベルでのユニットテストもできるので、エンティティごとの型検証やバインドされる値の検証も取りやすい&検証範囲も限定しやすいというメリットもあります。

そんなテストの恩恵とは裏腹に、Eloquent ORM を採用することによって、DB操作のロジックがダイナミックにカプセル化されてしまうために、ファンクションなどのユニットレベル、およびエンティティレベルの検証を取ることができなくなります。

まぁ、やろうと思えばできますけど「Eloquent ORMの提供元が既に検証済みですよね」って話であって、利用する側がわざわざ Active Record パターンのロジックを掘り起こしてまで検証する必要がなくて、本来求められているビジネスロジックの開発に集中ができるからフレームワークを使うわけです。やるメリットが無いというもの。

まして、任意にテストができる程度までに、Eloquent ORM の一部メソッドをモックすることができる、というのは既に承知していたので Eloquent ORM の存在が Repository Pattern の恩恵を薄く感じさせる要因の一つになってしまいました。

https://zenn.dev/hashi8084/articles/67f4ab362a7758

それなら隠蔽されないように Doctrine を使用してエンティティ層まで定義して Repository Pattern を完遂しちゃってもいいんじゃ無いの?って発想も当時はあったのですが、熟練度の観点から Eloquent ORM を完全に切り離したくなかったのと、そこまでするなら Laravel を採用するメリットが無くね?となるわけです。

Repository と Eloquent で責任範囲が重複する

Repository Pattern の良いところは、責任分解がよくなされていることで整備性が高くなる、という話なのですが、どの程度の単位で分解していくかという判断は、実装者側に委ねられ、かつ実装者間で差異が生まれるものと思ってます。
私としては、一番シンプルな形として「1:1」であり、一つのリポジトリーは、1つの役割・責任を担うように設計することが整備性を高め、単純明快な設計であると考えています。

で。悲しいかな、Active Record パターンによって「1テーブル:1クラス」という制約のもと交通整理がなされているために、 レポジトリー層、インターフェース層を設けることの恩恵であった交通整理が、同じようなルールで行われてしまっているのです。
インターフェース、リポジトリー、モデル、テーブル、という構成でそれぞれが1:1:1:1という構成になってしまうわけですが、1:2:3:4、みたいな構成になることも無い要件範囲ではあったので、将来性を持った設計というわけでもなく。

Eloquent ORMに差し替えてしまっても、サンプルで示したコード量が2倍、3倍に膨れることもないので、ここも恩恵にデバフがかかってしまいます。

単純なエンドポイントでも、実装が遠回りになりがち

前述の責任範囲と似たような話なのですが Repository Pattern は、ロジックをファイルに分割して責任分解をするわけなのですが、単純にファイル数が多くなります。

いわゆる「ルール」であり「規約」なので開発したソフトウェアの秩序は保たれるわけなのですが、Eloquent ORM を素直に使う方が Repository Pattern を採用した恩恵と同じような効果はあるし、ファイル数は少なくなります。そして、ファイル数が少ない方がライトであることは間違いなく・・

複雑な検索も無いCRUDの場合には、ファイル数が多いことによる工数増は、本来は望ましくはないので採用することが正義か、と言われてもそうでも無い状況とわかりました。
(小さなものを作りたいだけなのに、規約のせいで大きく作らざるを得ないというのは、それだけでもプログラマーがヤキモキしちゃいますよね汗)


結局 Laravel の提供パターンにならって作ることに。

これらの理由から、採用しても恩恵があまり無い、という判断に至ったわけです。
ぶっちゃけ Repository Pattern の採用実績はありますねぇ!と言いたいなぁ、という気持ちも後押ししていたところもあったので、格好つけられず残念。

採用しなかった現状は「Active Record + Service + API Resource パターン」みたいな構成で、実現したかったことに近い構成を作ろうという方針になりました。

  • Laravel のフレームワークにならって Fat Controller を防げそう
    • バリデーションは Form Request の rules メソッドに逃す
    • Modelの依存性注入には Route Model Binding を活用
    • ビジネスロジックは、サービス層を取り入れてコントローラーから逃す
    • サービス層が冗長化する場合は、別のサービスクラスとして分離
    • Json Response の様式は API Response Macro で定義し分離
    • レスポンスボディの指定は API Resource Pattern でオブジェクト構造定義だけを担う役割に分離
  • テストのためのデータリソースへの切り替えができるが Mockery がモックを担うため代替可能

こんな感じで、Laravel や Eloquent ORM に任せるところは任せて、ビジネスロジックに集中する環境を作る。動作検証も Feature レベルに引き上げてテストするようなプロジェクトで進めるように考えました。
まぁ正直、これぐらいでもだいぶ Controller の減量や、責任分解による整備性向上は叶う構成だなと考えています。

参考文献さま

まっぴーさまの文献を教科書として、理解と納得を進めて、どうにか形にしてきました。

https://zenn.dev/mpyw/articles/ce7d09eb6d8117

これを回避するための Criteria パターンというものもありますが, Eloquent のスコープ機能と目的が完全に被ります。無理に Laravel に導入するとなると, 「スコープ機能は使わない」 あるいは 「Eloquent を完全に捨てて Query Builder だけで縛る」 など厳しい制約が必要になってきます。

仮にそんなコードがあるとしましょう。何故あなたは Laravel を使うんですか?

小さいながら、いまでこそ「うんうん」と共感してしまうところです。。

Laravelに限らず、PHPは昔から緩めの規約で作れる文化が根強いから、いろんな作られ方がされる特徴があると思うのですが、こういった規約というのは、いろんなことを考えたくないエンジニアにとっては「ラクをするもの」と考えています。
最近はずっとこんなことを考える仕事が多いので、早く実装フェーズに入りたいものです。。

Discussion