💬

Laravelでサービス・リポジトリパターンは相性悪いのか

に公開

はじめに

LaravelはWeb開発の初心者でも素早くアプリケーションを構築できる素晴らしいフレームワークです。
私が今まで触れたフレームワークで、これ以上導入が簡単なものは今まで見たことがない・・・というわけではないですが、トップクラスです。
一方で、SymfonyやJavaのSpringなどのカッチリした思想のフレームワークに慣れていると逆にとまどうこともあります。

今回はLaravelのModelクラスにおけるローカルスコープと、サービス・リポジトリパターンとの折り合いで悩んだ話をしたいと思います。

悩みのタネ

LaravelでデータベースにアクセスするにはModelクラスに備わっているEroquant機能を使うことが推奨されています。

このModelクラスは非常に強力で、色々な機能があります。
それによって少ないコード量でサクッと書いていくことが可能なのですが、あまりにも機能が多すぎるという問題があります。

皆さんはファットコントローラという言葉はご存じでしょうか?
Controllerクラスに処理を詰め込むのは悪だという思想です。
MVCパターンを勉強していればフレームワークに関わらず出てきますね。
しかしファットモデルという言葉は教科書には出てこないと思います。

なぜなら、多くのWebフレームワークでモデル系のクラスが他のクラスに比べて極端に肥大化するという事はあまりないからです。

ところがLaravelのModelクラスはいわゆる神クラスなので、教科書通り書いていくとすぐにファットモデルになります。
なんせ公式がそれを推奨しているようなところがありますからね。

LaravelのモデルクラスはSpringなどに例えると以下の機能を内包しているといえます。

  • Entity(テーブルの 1 レコードを表すデータ構造)
  • 永続化・DB アクセスの責務(DAO / Repository 相当)
  • クエリ構築・検索条件のカプセル化(スコープを含む)
  • リレーション定義(hasMany / belongsTo など)
  • 属性のキャストやアクセサ・ミューテータ(getter/setterのセット、プロパティと呼ばれることが多い)
  • Observer を通じた副作用処理の起点

これ、全部1つのソースファイルに詰め込んだら相当太りますね。

そこで思いつくのがServiceクラス・Repositoryクラスを作って分割する手法です。
実際、何年も前からこれに挑戦し、そしてやっぱり相性悪いから戻した・・・というような記事もありました。

私も思い悩み、それでも綺麗なコードが書きたい!ということで行き着いた住みわけがコチラ。

SQLの組み立てはリポジトリクラスで行う。
複数のSQLの組み合わせ、データ取得前後の処理はサービスクラスで行う。
モデルにはリレーション定義・アクセサ/ミューテタなどを書くに留める。

そして、一番悩ましいローカルスコープですが、これは無理して使わず、リポジトリクラスで書くことを基本とする。
リポジトリクラスをFROM句に指定したテーブルごとに分けるように作成していれば、privateメソッドだけで事足りることがほとんどです。
むしろ半端にModelのスコープに持っていくと読みづらくなります。
ただ、例外的にクエリの一部に意味のある命名を行いたい場合はスコープに置き換えるのはアリとします。

こんなのとか。
「中止理由がなかったら、それは有効なレコードです。」という例。
いや、DB設計から直せというのは置いといて。
あえてカラム名並べただけだとわかりにくい例を創作しているので。

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Attributes\Scope;

#[Scope]
protected function active(Builder $query): void
{
    $query->whereNotNull('abort_reason');
}

モデルに書く内容についてまとめると、ロジックに依存しない、静的に近い内容にとどめるという事です。

ちなみにローカルスコープでは必ずクエリビルダーを返しましょう。
終端操作を書いちゃだめです。
終端操作っていうのはこういうやつ。

->get();
->first();

こういうのはリポジトリクラスでやります。
こういうやつね。

Repository
    public function selectTagsByUserSettings(int $userId)
    {
        $tags = DB::table('user_setting_tag')
            ->select('user_setting_tag.tag_id')
            ->join('user_settings', 'user_settings.id', '=', 'user_setting_tag.user_setting_id')
            ->where('user_settings.user_id', $userId)
            ->distinct()
            ->get()
            ->pluck('tag_id')
            ->toArray();

        return $tags;
    }

そうすると、サービスクラスが物凄くスッキリして気持ちいです。

Service
    public function getList(QuizDataConditionsDTO $conditions)
    {
        Log::info('▼▼▼ QuizDataService@getList ▼▼▼', ['conditions' => $conditions]);

        if ($conditions->isIgnoreSetting) {
            // 設定を無視する場合は全件取得
            $quizDataCollection = $this->quizDataRepository->selectAll();
        } {
            // ユーザ設定で登録したタグIDを取得
            $tagIds = $this->quizDataRepository->selectTagsByUserSettings($conditions->userId);

            // 取得したタグIDに紐づくクイズデータを取得
            $quizDataCollection = $this->quizDataRepository->selectByUserSettings($conditions->userId, $tagIds);
        }

        return $quizDataCollection;
    } 

もちろんコントローラもシンプルに保ちます。
リクエストで受け取ったものをサービスに処理してもらってレスポンスに返すだけです。
他にはログイン情報使っているくらい。

Controller
    public function index(QuizDataRequest $request)
    {
        // ログインユーザを取得
        $userId = Auth::id();

        if (!$userId) {
            return response()->json(['message' => 'Unauthenticated'], 401);
        }

        Log::info('Logged-in user id: ' . $userId);

        $isIgnoreSetting = $request->input('is_ignore_setting', true);

        $condtions = new QuizDataConditionsDTO(
            userId: $userId,
            isIgnoreSetting: $isIgnoreSetting,
        );

        $quizDataList = $this->quizDataService->getList($condtions);

        return response()->json($quizDataList, 200);
    }

余談ですが、PHPは連想配列が強力なのでどの教科書でも型を縛る方法は教えてくれませんが、このように自分でDTOクラスを作ることで型安全なコードにできます。
こんなコードは私以外で書いているのを見たことがないので、アンチパターンかもしれませんが。

PHP8ではこんなコードが書けるようになったので活用したいですね。

DTO
<?php

namespace App\DataTransferObjects;

class QuizDataConditionsDTO
{
    public function __construct(
        public readonly int $userId,
        public readonly bool $isIgnoreSetting,
    ) {}
}

おわりに

どういうコードが最高か、というのは年々移り変わっていきますし、デザインパターンも新しいものが生み出されていきます。
言語そのものやフレームワークの機能が変われば過去に良かったものが時代遅れになることもあります。

また、私自身、経験が少ないときは理解できずに何となく使っていたことや、デザインパターンのメリットが分からずに太ったコードを書いたり、逆に新しく知ったことを試したくてアプリケーションの規模に見合わない固さで書いたり、失敗の積み重ねです。

今まで正しいと思っていたことが、他の言語の常識を知って、「あれ?これPHPもこうした方がいいんじゃね?」ってなることもあります。

是非、みなさんも色々なコードに触れて新しいやり方を考えてみてください。
それはAIには出来ない、人間の特権なのかもしれません。

株式会社ONE WEDGE

【Serverlessで世の中をもっと楽しく】 ONE WEDGEはServerlessシステム開発を中核技術としてWeb系システム開発、AWS/GCPを利用した業務システム・サービス開発、PWAを用いたモバイル開発、Alexaスキル開発など、元気と技術力を武器にお客様に真摯に向き合う価値創造企業です。
https://onewedge.co.jp

Discussion