Shin x Blog

PHPをメインにWebシステムを開発してます。Webシステム開発チームの技術サポートも行っています。

Laravel におけるリポジトリ実装のポイント

Laravel を使った開発でも、ドメインロジックと RDBMS などの永続化層へのアクセスを分離するためにリポジトリパターンを採用するケースが増えてきました。

ただ、Laravel には Eloquent という Active Record タイプの ORM があるので、これとリポジトリをどのように組み合わせるかで悩んでいる人が多いようで、これまで開発現場や勉強会などで質問を受けることがありました。

本エントリでは、リポジトリを実装してきた経験を元に、私が考える実装のポイントをご紹介します。

1. ドメインデータの入出力にリポジトリパターンを使う

リポジトリパターンは、ビジネスロジックの実行に必要なデータを RDBMS のようなデータストアから入出力する際に利用します*1。あくまで起点となるのはドメインデータです。

本エントリでは、ビジネスロジックの実行に必要なデータを、DDD で登場する Entity や Value Object、Aggregate などのドメインモデルはもちろんのこと、stdClass や array、 string、int などの組み込みの型も含める意図で、ドメインデータと呼びます。

Eloquent のような Active Record では、1 テーブルにつき、1 クラスといった具合にテーブルを写すものとして実装しますが、リポジトリはそうではありません。一つのテーブルから複数のリポジトリが実装される場合もありますし、複数のテーブルを集約したリポジトリを実装することもあります。

ドメインデータの入出力のためにリポジトリを使うという点をまず意識します。

2. メソッドの型宣言にドメインデータを指定する

リポジトリのインターフェイスは、1. を意識して定義します。このインターフェイスでは、ドメインデータの入出力を示すので、メソッドの引数や戻り値では、ドメインデータを型として指定します。つまり、Eloquent を引数にしたり戻り値にしたりはしないということです。

下記では、UserRepository という User モデルの入出力を担うリポジトリインターフェイスを定義しています。この User は、POPO(Plain Old PHP Object) で実装したドメインデータであり、Eloquent ではないので注意して下さい。

findById メソッドでは、UserId クラス $id を引数に取り*2、User モデルを返しています。store メソッドでは、User モデルを引数に取ります。

このインターフェイスを見れば、User モデルに関するリポジトリであることは分かりますが、実際にどのような方法でデータストアへアクセスして入出力を行うのかは分かりません。リポジトリをドメインデータの入出力を行うものとして捉えれば、これで十分なわけです。

<?php

namespace Acme\Domain\Repository;

use Acme\Domain\Model\User;
use Acme\Domain\Model\UserId;

interface UserRepository
{
    /**
     * @param UserId $id
     * @return User
     */
    public function findById(UserId $id): User;

    /**
     * @param User $user
     * @return UserId
     */
    public function store(User $user): UserId;
}

3. 機械的に CRUD メソッドを実装しない

先に書いたとおり、リポジトリはドメインデータの入出力を行うものです。つまり、ドメインデータとして必要な入出力のみを定義すれば良いのです。アプリケーションに、あるドメインデータを削除する概念が無いのなら、remove メソッドは不要です。読み取る際も、ID がキーなのか、Email なのか、複数の条件なのかはドメインデータによって様々です。

また、結果としては同じ users テーブルに対する保存であっても、上記の UserRepostitory::store メソッドのようにモデルをそのまま永続化するメソッドがあったり、ユースケースによってはメールアドレスだけを保存するメソッドがあっても構わないということです。

機械的に CRUD を付けるというのは、データベーステーブルに対応するリポジトリを実装するという発想から来るものでしょう。そうではなく、リポジトリでは、対象のドメインデータが永続化層にどのような操作が必要かを考えて、それのみを実装すると良いです。

4. Eloquent を利用したリポジトリクラスの実装

リポジトリのインターフェイスさえ決まれば、後の実装は簡単です。インターフェイスの定義を満たすように実装していくだけです。

下記では、UserRepository インターフェイスを Eloquent を使って実装しています。EloquentUser は、users テーブルに対する Eloquent です。findById メソッド、store メソッド共に、この EloquentUser を使って実装しています。

findById メソッドでは、EloquentUser を使って、レコードを取得し、その結果を User モデルにセットして返しています。

store メソッドでは、User モデルを引数に取っており、必要な EloquentUser インスタンスにセットして保存しています。

このようにドメインデータを入出力に使うようにインターフェイスを定義しておけば、その実装に Eloquent を利用しても、それはあくまでリポジトリクラス内部の実装方法に過ぎず、何ら問題ありません。

<?php

namespace Acme\Infrastructure\Repository;

use Acme\Domain\Exception\NotFoundException;
use Acme\Domain\Model\User;
use Acme\Domain\Model\UserId;
use Acme\Domain\Repository\UserRepository;
use Acme\Infrastructure\Eloquent\EloquentUser;

final class EloquentUserRepository implements UserRepository
{
    /** @var EloquentUser */
    private $eloquentUser;

    /**
     * @param EloquentUser $eloquentUser
     */
    public function __construct(EloquentUser $eloquentUser)
    {
        $this->eloquentUser = $eloquentUser;
    }

    /**
     * @param UserId $id
     * @return User
     * @throws NotFoundException
     */
    public function findById(UserId $id): User
    {
        /** @var EloquentUser $eloquent */
        $eloquent = $this->eloquentUser->newQuery()->find($id->asInt());
        // Facade を使っても構わない
        // $eloquent = EloquentUser::find($id->asInt());
        
        if ($eloquent === null) {
            throw new NotFoundException('user not found:' . $id);
        }

        return new User(
            new UserId($eloquent->id),
            $eloquent->name,
            $eloquent->email
        );
    }

    /**
     * @param User $user
     * @return UserId
     */
    public function store(User $user): UserId
    {
        if ($user->hasId()) {
            $eloquent = $this->eloquentUser->newQuery()->findOrFail($user->getId()->asInt());
        } else {
            $eloquent = $this->eloquentUser->newInstance();
        }

        $eloquent->name = $user->getName();
        $eloquent->email = $user->getEmail();
        $eloquent->save();

        return new UserId($eloquent->id);
    }
}

5. 複数テーブルを扱うリポジトリ

これまで述べたとおり、リポジトリはテーブルと 1 対 1 で実装する必要はありません。つまり、複数テーブルからデータを取得して、一つのドメインデータとして返す場合もあります。

下記は、PointEmail モデルを取得する PointEmailRepository です。findByUserId メソッドで PointEmail モデルを取得します。このインターフェイスでは、どのテーブルのどのカラムから値を取得するかは分からず、また知る必要もありません。分かるのは、UserId を渡せば、PointEmail が返ってくるということだけです。

<?php

namespace Acme\Domain\Repository;

use Acme\Domain\Model\PointEmail;
use Acme\Domain\Model\UserId;

interface PointEmailRepository
{
    /**
     * @param UserId $id
     * @return PointEmail
     */
    public function findByUserId(UserId $id): PointEmail;
}

PointEmail をデータベースから取得するには、users.email と user_points.point が必要となります。これを実装しているのが、下記の EloquentPointEmailRepository です。findByUserId メソッド内では、テーブルを join してレコードを取得し、それを PointEmail インスタンスとして返しています。

このように複数テーブルから値を取得する際もレコードを取得して、ドメインデータにセットして返せば良いのです。

もし、ドメインデータに必要な値のデータソースが異なる場合、例えば、データベースにある値と Redis にある値が必要な場合でも理屈は同じです。リポジトリインターフェイスでは、ドメインデータを返す定義を書いておき、それぞれのデータソースからの取得処理はリポジトリクラスで書けば良いのです。

<?php

namespace Acme\Infrastructure\Repository;

use Acme\Domain\Exception\NotFoundException;
use Acme\Domain\Model\PointEmail;
use Acme\Domain\Model\UserId;
use Acme\Domain\Repository\PointEmailRepository;
use Acme\Infrastructure\Eloquent\EloquentUser;

final class EloquentPointEmailRepository implements PointEmailRepository
{
    /** @var EloquentUser */
    private $eloquentUser;

    /**
     * @param EloquentUser $eloquentUser
     */
    public function __construct(EloquentUser $eloquentUser)
    {
        $this->eloquentUser = $eloquentUser;
    }

    /**
     * @param UserId $id
     * @return PointEmail
     * @throws NotFoundException
     */
    public function findByUserId(UserId $id): PointEmail
    {
        $record = $this->eloquentUser->newQuery()
            ->join('user_points', 'user_points.user_id', '=', 'users.id')
            ->where('id', $id->asInt())
            ->first(['email', 'point']);

        if ($record === null) {
            throw new NotFoundException('PointEmail not found:' . $id);
        }

        return new PointEmail(
            $record->email,
            $record->point
        );
    }
}

これはデータストアへの保存についても同じです。一つのドメインデータを複数テーブルに保存する場合も良くありますが、その時はリポジトリの中でトランザクションを開始して、各テーブルにデータを保存した後にコミットすると良いでしょう。もちろん、ケースバイケースにはなりますが、ドメインデータを入出力するリポジトリという視点ではこれがやりやすいです。

6. Paginator との連携

Laravel は、Eloquent との連携で便利に使える機構があります。例えば、Paginator はその一つでしょう。Eloquent を使えば、paginate メソッドで簡単にデータベースから必要な値を取得して、ビューに渡す Paginator インスタンスを生成できます。

一方、リポジトリを実装した場合、Eloquent はリポジトリの中で閉じており、戻り値にはドメインデータが返されるのでこうした機構が利用できないように見えます。

実は、Paginator は、データベースへのアクセスと Paginator の処理は独立しており、下記のようなコードで任意のデータに対して Paginator インスタンスを生成できます。つまり、総件数の算出やデータの抽出はリポジトリで行って、後は Paginator(下記では、LengthAwarePaginator )インスタンスに与えるだけです。

これをビューファイルに渡せば、ページングなどに必要なリンクを blade で出力できます。

<?php

namespace Acme\Application\Action;

use Acme\Application\Repository\UserPaginatorRepository;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;

final class UserListAction
{
    private const PER_PAGE = 10;

    public function __invoke(UserPaginatorRepository $repository)
    {
        $page = Paginator::resolveCurrentPage();
        
        // リポジトリで総件数と 1 ページ分のデータを取得
        $total = $repository->count();
        $results = $repository->findUserList($page, self::PER_PAGE);

        // LengthAwarePaginator インスタンスの生成
        $paginator = new LengthAwarePaginator($results, $total, static::PER_PAGE, $page);

        return view('users.list', [
            'paginator' => $paginator,
        ]);
    }
}

もう一つの考え方としては、本エントリの趣旨からはずれますが、Paginator はビュー(プレゼンテーション)の関心事なので、Eloquent を生で使って実装するという方法もあります。このあたりは、何を目的にリポジトリを使うのかという観点で検討すれば良いでしょう。

さいごに

リポジトリの実装では、どのようにリポジトリインターフェイスを定義するかが肝になります。これが上手くいけば、後はそれを満たす実装を書くだけなので、迷うことは少なくなります。

本エントリのように、ドメインデータを引数や戻り値に指定するインターフェイスを書けば、 リポジトリクラスでは、インターフェイスに沿って、データストアからデータを取得して、目的のデータ構造で返すだけなので、極論を言えば、Eloquent で実装しようが、Query Builder で実装しようが、Doctrine で実装しようが、それは実装の詳細に過ぎないということが分かるでしょう。*3

Laravel には、標準で Eloquent と Query Builder という強力な DAO が備わっているので、それらを活用したい人は多いでしょう。これまで示してきたように、Eloquent を利用してリポジトリを実装することは何ら問題無いので、上手く活用していきましょう。

*1:理想は、入出力すら意識せずに操作できることですが、実際のところ入出力であることは意識して実装することが多いです。

*2:int のような組み込みの型でも構いません。

*3:もちろん、実装の詳細は重要なので、データストアへのアクセスをどう実装するのが良いかというは視点では十分に検討する必要があります