Laravelで安易にRepositoryやServiceを作らない方が良いと思う

#laravel #php #architecture

LaravelのプロジェクトでController/Service/Repositoryの3層構成を見かけることが多く、層を分けること・責務を分離することの意味について改めて考えてみました。 この記事は、その中で感じた疑問や考えをまとめたものです。

TL;DR

  • Controller/Service/Repositoryの3層構成は、DDDの設計を中途半端に借りた構成である
  • Active Recordパターンとリポジトリパターンは思想が対立しており、Active Recordの上にリポジトリを被せることはその利便性を自ら封じることになる
  • リポジトリパターンの利点に対して、Active Recordの利便性を失うコストが大きすぎる
  • 3層構成におけるサービス層はドメインモデル貧血症を助長しやすい
  • 複雑なドメインを扱うならDDDを徹底すべきであり、そうでなければUseCaseクラス・Eloquentスコープ・Custom Query Classを活用し、Laravelの設計思想に沿ったアプローチをとるべき

以下では、まずActive Recordパターンとリポジトリパターンの思想の違いを述べた上で、このレイヤー構成が良くないと思う理由を3つ述べ、代替アプローチを紹介します。

前提: Active Recordパターンの上にリポジトリを被せるということ

まず、リポジトリパターンとActive Recordパターンの思想を確認します。

Martin Fowlerによれば、リポジトリパターンとは以下のようなものです。

A Repository mediates between the domain and data mapping layers, acting like an in-memory domain object collection. (リポジトリはドメインとデータマッピング層の間を仲介し、インメモリのドメインオブジェクトコレクションのように振る舞う) — Martin Fowler, Patterns of Enterprise Application Architecture

リポジトリはData Mapperパターンと親和性が高い設計です。Data Mapperパターンでは、ドメインオブジェクトは永続化の仕組みを一切知りません。Doctrineのように、エンティティは純粋なオブジェクトであり、永続化はマッパーが担います。ドメインオブジェクトがそもそもデータアクセスの手段を持たないからこそ、リポジトリがその窓口を担うのです。

一方、Active Recordパターンとは以下のようなものです。

An object that wraps a row in a database table or view, encapsulates the database access, and adds domain logic on that data. (データベースのテーブルやビューの行をラップし、データベースアクセスをカプセル化し、そのデータにドメインロジックを加えるオブジェクト) — Martin Fowler, Patterns of Enterprise Application Architecture

LaravelのEloquentはまさにこのパターンの実装です。データの表現・クエリの構築・リレーションの定義・振る舞いの記述を一つのクラスに統合しています。

$book = Book::find(1);                          // 主キー検索
$book->author;                                  // リレーションの遅延読み込み
$book->title = 'New Title';                     // ミューテタ・キャスト
$book->save();                                  // 永続化

Book::query()->wherePublished()->latest()->get(); // スコープの合成

このように、Eloquentモデルはデータアクセスから振る舞いまでを一つのオブジェクトで完結させます。

この2つのパターンの思想は対立しています。リポジトリパターンは「ドメインオブジェクトがデータアクセスを知らない」ことを前提としますが、Active Recordパターンは「オブジェクト自身がデータアクセスを担う」ことを前提とします。

Active Recordの上にリポジトリを被せるとどうなるでしょうか。

  • Eloquentが提供するスコープの合成、リレーションの遅延読み込み、ミューテタ、キャスト、イベントといった機能を、リポジトリの背後に隠すか、使わないことになる
  • ドメインオブジェクトをEloquentモデルとは別に用意し、変換処理を書く必要がある
  • Active Recordの利便性を自ら封じた上で、Data Mapper的な設計を手動で再構築することになる

これだけの便利さを捨ててまで、リポジトリパターンを導入する理由が本当にあるでしょうか。次のセクション以降では、この問題をより具体的に見ていきます。

理由1: リポジトリパターンで得るものと失うもの

リポジトリパターンには明確な利点があります。

  • データアクセスの実装を差し替えることができる
  • DBに依存しないテストを書ける
  • 変更範囲の局所化

これらの利点自体は否定しません。しかし、Laravelでリポジトリパターンを導入するには、以下のようなコストを支払う必要があります。

まず、リポジトリはデータ永続化を隠蔽する必要があるので、データアクセスロジックを内包しているEloquentモデルを返すわけにはいきません。よって、以下のようにインターフェースとその実装を用意することになります。

interface BookRepositoryInterface
{
    public function findById(int $id): ?Book;
    public function findBySlug(string $slug): ?Book;
    /** @return Book[] */
    public function getAll(): array;
    /** @return Book[] */
    public function getPublished(): array;
    public function save(Book $book): void;
    public function delete(int $id): void;
}
use App\Models\Book as BookModel;

class BookEloquentRepository implements BookRepositoryInterface
{
    public function findById(int $id): ?Book
    {
        $model = BookModel::find($id);
        return $model ? Book::fromModel($model) : null;
    }

    public function findBySlug(string $slug): ?Book
    {
        $model = BookModel::where('slug', $slug)->first();
        return $model ? Book::fromModel($model) : null;
    }

    public function getPublished(): array
    {
        return BookModel::whereNotNull('published_at')
            ->get()
            ->map(fn (BookModel $model) => Book::fromModel($model))
            ->all();
    }

    // ...
}

これはEloquentモデルがすでに持っている機能(findwhere、スコープなど)を、リポジトリのインターフェースとして再構築しているだけです。さらに、ServiceProviderでのバインドも必要になります。

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(BookRepositoryInterface::class, BookEloquentRepository::class);
    }
}

では、先ほどの利点はこのコストに見合うでしょうか。

データアクセスの実装の差し替えについては、Laravelを使っている限りにおいて、ORMをEloquent以外に差し替える場面は現実的にはほとんどありません。

DBに依存しないテストについては、確かに利点ではあると思いますが、Laravelは実際のデータベースを使用した統合テストを重視しています。RefreshDatabaseトレイトなどが提供されており、データベースを使用したテストを簡単に書くことができます。 ただし、データアクセス以外の純粋なロジックに対するテストを書きたくなるほどドメインが複雑であれば、リポジトリパターンを採用しても良いサインかもしれません。

変更範囲の局所化については、確かに、データアクセスの修正がリポジトリに閉じるため、意図せずにビジネスロジックが変更されてしまう可能性を小さくすることができます。ただし、Eloquentモデルにおいても、スコープやCustom Query Classを用いることによって、データアクセスを切り出すことはある程度可能です。

私がここで言いたいのは、リポジトリパターンの利点を否定しているのではなく、そのドメインは本当にリポジトリパターンを導入するほど複雑なのか?ということです。Eloquentのスコープやモデルの振る舞いでも、ある程度の関心事の分離は実現できます。

また、リポジトリパターンを導入してデータアクセスをドメインモデルから分離したとき、ドメインモデルに残る純粋なビジネスロジックは、CRUDが中心のアプリケーションでは意外と少ないものです。Active Recordの利便性を捨てた結果、薄いドメインモデルと大量のボイラープレートが残るだけになりかねません。

Active Recordの利便性を捨ててまで得たいものが本当にあるのか、一度立ち止まって考えてみても良いのではないでしょうか。

理由2: リポジトリにはコンポーザビリティがない

リポジトリのメソッドは、それぞれが独立した、完結したクエリを表現します。

たとえば「公開済みの書籍を取得する」「プレミアム書籍を取得する」という2つの要件があるとき、リポジトリでは getPublished()getPremium() を別々に定義します。では「公開済みのプレミアム書籍」が必要になったらどうでしょうか? getPublishedPremium() という新しいメソッドを追加するしかありません。

条件の組み合わせが増えるたびにメソッドが増えていくのは、リポジトリのメソッドが組み立て可能ではないからです。各メソッドは完結した操作であり、他のメソッドと合成することができません。

リポジトリをクエリビルダーのように実装することもできなくはないかもしれないですが、それはEloquentのスコープが既に提供している仕組みの再発明であり、コストに見合いませんし分かりにくくなると思います。

一方、Eloquentのスコープはクエリ条件を部品として定義します。wherePublished()wherePremium() を用意すれば、メソッドチェーンで自由に組み合わせられます。

// 組み合わせは呼び出し側で自由にできる
Book::query()->wherePublished()->wherePremium()->get();
Book::query()->wherePublished()->whereFree()->latest()->get();

新しい組み合わせが必要になっても、スコープを追加するだけで既存のスコープと自由に合成できます。リポジトリのように組み合わせごとにメソッドを用意する必要がありません。

スコープはモデルに定義されるため、データ構造とフィルタリングロジックが同じ場所に共存し、Laravelの設計思想においては自然な配置になります。

理由3: ドメインモデル貧血症になりやすい

リポジトリパターンとサービス層を組み合わせたパターンでよくありがちなのは、以下のような棲み分けです。

  • ドメインモデルはデータの入れ物
  • サービス層はリポジトリのオーケストレーションを伴うロジック
  • リポジトリはデータ永続化

これは綺麗な棲み分けに見えます。

しかし、データとロジックの分離はカプセル化を放棄することです。

カプセル化は、データとその振る舞いを一体として扱うことであり、これによりドメインモデルは凝集度が高くなり、豊かな表現力を持つようになります。

これを捨てると、ドメインモデルはただのデータの入れものになり、ロジックはサービスクラスに手続きとして書かれてしまいます。

Martin Fowlerはこれをドメインモデル貧血症(Anemic Domain Model)と呼び批判しました。

もちろん、サービスを使いつつモデルに markAsPublished() のようなメソッドを持たせることは可能です。しかし実際には、サービスが「ロジックの置き場所」として認識されることで、モデルに振る舞いを持たせる動機が薄れ、以下のようなコードが生まれがちです。

class BookService
{
    public function __construct(
        private BookRepositoryInterface $bookRepository,
    ) {}

    public function publish(int $bookId, int $userId, bool $shouldBePremium): void
    {
        $book = $this->bookRepository->findById($bookId);
        $book->published_at = now();
        $book->published_by = $userId;

        if ($shouldBePremium) {
            $book->is_premium = true;
        }

        $this->bookRepository->save($book);
    }
}

「公開する」という振る舞いがモデルから切り離され、サービスがデータを直接操作しています。モデルはただのデータ構造体になってしまっています。

例外: リポジトリパターンが正当化されるケース

ここまでリポジトリパターンの問題点を述べてきましたが、複雑なドメインを扱う場合にDDDの文脈でリポジトリパターンを採用すること自体は妥当だと考えています。

DDDでは、リポジトリ・ドメインサービス・アプリケーションサービスがそれぞれ明確な責務を持ち、ドメインオブジェクトがリッチであり続けられる設計になっています。

一方、Controller/Service/Repositoryの3層構成は、この設計を 中途半端に取り入れた結果 です。DDDのリポジトリだけを借りてきて、サービス層はドメインサービスとアプリケーションサービスの区別なく一枚岩の「Service」に押し込めています。だからこそ、サービス層の責務が曖昧になり、前述のような問題が起きやすくなるのです。

つまり、やるならDDDの思想に沿って徹底的にやるべきであり、Controller/Service/Repositoryは中途半端な選択と言えます。そして、そこまでの複雑さが求められないプロジェクトでは、次に述べるようなLaravelのフレームワークの強みを活かしたアプローチのほうが適切です。

リポジトリやサービスを使わずにどうするか

記事「5年間 Laravel を使って辿り着いた,全然頑張らない「なんちゃってクリーンアーキテクチャ」という落としどころ」では、リポジトリやサービス層を使わず、UseCaseクラスとEloquentモデルを中心に据えたシンプルな構成が提案されています。この考え方をベースに、代替アプローチを紹介します。

ビジネスオペレーションにはUseCaseクラスを使う

サービスクラスの代わりに、1つのビジネスオペレーションに1つのUseCaseクラスを対応させます。

class PublishBookUseCase
{
    public function __invoke(Book $book, User $user, bool $shouldBePremium): void
    {
        $book->markAsPublishedBy($user);

        if ($shouldBePremium) {
            $book->markAsPremium();
        }

        $book->save();
        event(new BookPublishedEvent($book));
    }
}

注目していただきたいのは、markAsPublishedBymarkAsPremiumといった振る舞いがモデル自身に定義されていることです。UseCaseはモデルの振る舞いを組み合わせてビジネスオペレーションを実行する役割に徹しており、ドメインモデルがデータと振る舞いの両方を持つリッチなモデルであり続けられます。

Eloquentのスコープを活用する

フィルタリングやクエリの条件はEloquentのスコープとして表現することで、データ構造とフィルタリングロジックが同じ場所に共存する、Laravelにとって自然な形で整理できます。

class Book extends Model
{
    public function scopeWherePublished(Builder $query): void
    {
        $query->whereNotNull('published_at');
    }

    public function scopeWhereFree(Builder $query): void
    {
        $query->where('is_premium', false);
    }

    public function scopeWherePremium(Builder $query): void
    {
        $query->where('is_premium', true);
    }

    public function markAsPublishedBy(User $user): void
    {
        $this->published_at = now();
        $this->published_by = $user->id;
    }

    public function markAsPremium(): void
    {
        $this->is_premium = true;
    }
}
// 使用例
Book::query()
    ->wherePublished()
    ->when(!Auth::user()?->isPremium(), fn (Builder $query) => $query->whereFree())
    ->latest()
    ->limit(10)
    ->get();

スコープはモデルに属しているので、関連するロジックが散らばらず、組み合わせも自由自在です。リポジトリにメソッドを足し続ける必要はありません。

Eloquentモデルに振る舞いを持たせていくと、モデルが太っていくという懸念があるかもしれません。しかし、関心事ごとにトレイトへ分割することでモデルクラス自体はスリムに保てます。たとえば「公開」に関するスコープと振る舞いをPublishableトレイトにまとめれば、モデルのuse宣言を見るだけでそのモデルがどんな関心事を持っているかが一覧できます。

class Book extends Model
{
    use Publishable;

    public function scopeWhereFree(Builder $query): void
    {
        $query->where('is_premium', false);
    }

    public function scopeWherePremium(Builder $query): void
    {
        $query->where('is_premium', true);
    }

    public function markAsPremium(): void
    {
        $this->is_premium = true;
    }
}
trait Publishable
{
    public function scopeWherePublished(Builder $query): void
    {
        $query->whereNotNull('published_at');
    }

    public function markAsPublishedBy(User $user): void
    {
        $this->published_at = now();
        $this->published_by = $user->id;
    }
}

複雑なクエリにはCustom Query Classを使う

スコープだけでは表現しきれない複雑なクエリは、専用のクラス(Custom Query Class)に切り出すのが良いと思います。

class LatestPremiumBooksOfCategoryQuery
{
    public function __construct(
        private readonly Category $category,
        private readonly int $limit,
    ) {}

    public function get(): Collection
    {
        return $this->category
            ->books()
            ->wherePublished()
            ->wherePremium()
            ->latest()
            ->take($this->limit)
            ->get();
    }
}

// 使用例
(new LatestPremiumBooksOfCategoryQuery($category, config('categories.limit')))->get();

1つのクエリが1つのクラスに対応しているので、責務が明確です。リポジトリのように1つのクラスに多数のクエリメソッドが蓄積していく問題がありません。

まとめ

Controller/Service/Repositoryの3層構成は、一見すると整理されているように見えますが、以下の問題を引き起こしやすいです。

  • リポジトリはActive Recordと思想が対立しており、その利便性を失うコストに対して得られるメリットが見合わないことが多い
  • リポジトリにはコンポーザビリティがなく、条件の組み合わせごとにメソッドが増え続ける
  • サービス層はドメインモデル貧血症を助長する

複雑なドメインを扱う大規模開発であれば、DDDの思想に沿ってドメインサービス・アプリケーションサービス・リポジトリを適切に設計すべきだと思います。 一方、そこまでの複雑さが求められないプロジェクトでは、Laravelの強みを活かした以下のアプローチが適切だと思います。

関心事アプローチ
フィルタリング・クエリ条件Eloquentスコープ
複雑なクエリCustom Query Class
ビジネスオペレーションUseCaseクラス + リッチなEloquentモデル
共通ビジネスルールサービスクラス(必要に応じて)

Controller/Service/Repositoryは、DDDの設計を中途半端に借りてきた構成であり、どちらの方向にも振り切れていない中途半端な構成になってしまっていると思います。

「Controller/Service/Repositoryはレイヤーが明確で、チームにとってわかりやすい」という反論もあるかもしれません。しかし、サービス層に何を書くかは実は自明ではありません。再利用性を重視すればサービスはリポジトリの薄いラッパーになりがちですし、コントローラーを薄くしようとすればサービスはユースケース層に近づいていきます。「どこに何を書くか」の判断が結局必要になる以上、この構成が「簡単でわかりやすい」とは言い切れません。

大切なのは、フレームワークに逆らうのではなく、フレームワークの思想を理解した上で設計を選ぶことだと思います。 Laravelはアクティブレコードパターンを採用しており、Eloquentモデルがデータと振る舞いを両方持つことを前提としています。 その設計思想を活かすことが、最もシンプルで保守しやすいコードにつながるのではないでしょうか。

参考文献