【Laravel】Service・Repositoryパターンを導入するメリットと実装例

laravelアイキャッチ Laravel

こんにちは、かつコーチです。

前回は「太らせないControllerの書き方」というテーマで、Controllerの責務を最小限に絞る考え方を扱いました。

では、Controllerから追い出したロジックはどこに行くべきなのでしょうか。

今回はその受け皿となる、Service・Repositoryパターンについて解説していきます。

なぜService・Repositoryパターンが必要なのか

Controllerが太る問題のおさらい

前回の記事でも触れましたが、ControllerにDB操作やビジネスロジックをそのまま書き続けると、あっという間に肥大化します。

public function store(Request $request)
{
    $validated = $request->validate([
        'title' => 'required|max:255',
        'body' => 'required',
    ]);

    $post = new Post();
    $post->title = $validated['title'];
    $post->body = $validated['body'];
    $post->user_id = auth()->id();
    $post->slug = Str::slug($validated['title']);
    $post->save();

    if ($post->user->is_premium) {
        Notification::send($post->user, new PostPublishedNotification($post));
    }

    return redirect()->route('posts.show', $post);
}

バリデーション、DB操作、通知処理まで、すべてが1つのメソッドに詰め込まれています。

このままテストを書こうとすると、DBや通知の仕組みまで一緒に動かさないと確認できず、テストが重くなります。

ServiceとRepositoryの役割分担

そこで登場するのが、Service層とRepository層という2つの層です。

役割はシンプルに分かれています。

  • Repository:データの取得・保存を担当する層(Eloquentへの問い合わせ窓口)
  • Service:業務ロジック(ビジネスルール)を担当する層(Repositoryやその他の処理を組み合わせる)

Controllerは「リクエストを受け取り、Serviceに処理を依頼し、結果をレスポンスとして返す」だけの役割に絞られます。

Repositoryパターンの実装

Interfaceを定義する

まずはRepositoryのInterfaceを定義します。

Interfaceを挟むことで、後述するテストのしやすさや実装の差し替えやすさが手に入ります。

// app/Repositories/PostRepositoryInterface.php
namespace App\Repositories;

use App\Models\Post;
use Illuminate\Support\Collection;

interface PostRepositoryInterface
{
    public function find(int $id): ?Post;
    public function all(): Collection;
    public function create(array $data): Post;
}

Eloquent実装クラスを作る

Interfaceに対して、Eloquentを使った実装クラスを用意します。

// app/Repositories/EloquentPostRepository.php
namespace App\Repositories;

use App\Models\Post;
use Illuminate\Support\Collection;

class EloquentPostRepository implements PostRepositoryInterface
{
    public function find(int $id): ?Post
    {
        return Post::find($id);
    }

    public function all(): Collection
    {
        return Post::latest()->get();
    }

    public function create(array $data): Post
    {
        return Post::create($data);
    }
}

サービスコンテナでbindする

InterfaceとEloquent実装を紐づけるために、サービスコンテナに登録します。

// app/Providers/AppServiceProvider.php
namespace App\Providers;

use App\Repositories\EloquentPostRepository;
use App\Repositories\PostRepositoryInterface;
use Illuminate\Support\ServiceProvider;

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

こうしておくと、コンストラクタで PostRepositoryInterface を型指定するだけで、Laravelが自動的に EloquentPostRepository を注入してくれます。

Serviceパターンの実装

ビジネスロジックをServiceクラスに集約

続いて、業務ロジックを担当するServiceクラスを作ります。

// app/Services/PostService.php
namespace App\Services;

use App\Models\Post;
use App\Notifications\PostPublishedNotification;
use App\Repositories\PostRepositoryInterface;
use Illuminate\Support\Str;

class PostService
{
    public function __construct(
        private PostRepositoryInterface $postRepository
    ) {}

    public function createPost(array $validated, int $userId): Post
    {
        $post = $this->postRepository->create([
            'title' => $validated['title'],
            'body' => $validated['body'],
            'user_id' => $userId,
            'slug' => Str::slug($validated['title']),
        ]);

        if ($post->user->is_premium) {
            $post->user->notify(new PostPublishedNotification($post));
        }

        return $post;
    }
}

冒頭のControllerにあった「保存」と「通知」のロジックが、このクラスに移りました。

Controllerから呼び出す

最後にControllerを整理すると、次のようにすっきりします。

namespace App\Http\Controllers;

use App\Http\Requests\StorePostRequest;
use App\Services\PostService;

class PostController extends Controller
{
    public function __construct(
        private PostService $postService
    ) {}

    public function store(StorePostRequest $request)
    {
        $post = $this->postService->createPost(
            $request->validated(),
            auth()->id()
        );

        return redirect()->route('posts.show', $post);
    }
}

Controllerは「受け取って、渡して、返す」だけになり、何をしているかが一目で分かるようになりました。

つまずきポイント:形だけのRepositoryにしてしまう問題

かつコーチが実際につまずいた話

正直に告白すると、私が最初にRepositoryパターンを導入したとき、恩恵をまったく感じられませんでした。

❌ Before:Repositoryが薄いラッパーになっているだけ

class EloquentPostRepository implements PostRepositoryInterface
{
    public function all(): Collection
    {
        return Post::all(); // ただEloquentを呼んでいるだけ
    }
}

Post::all()$repository->all() に置き換えただけで、複雑なクエリの共通化もテストの差し替えも一切活かせていませんでした。

「ただ回り道して同じことをしているだけ」の状態です。

✅ After:複雑なクエリをRepositoryに集約し、テストでは差し替える

class EloquentPostRepository implements PostRepositoryInterface
{
    public function findPublishedWithComments(int $id): ?Post
    {
        return Post::with(['comments' => fn ($q) => $q->approved()])
            ->where('is_published', true)
            ->findOrFail($id);
    }
}
// テストではInterfaceを差し替えて、DBに依存せず検証できる
$this->app->bind(PostRepositoryInterface::class, function () {
    return new FakePostRepository();
});

複数箇所で使い回す複雑なクエリをRepositoryに閉じ込め、テストではフェイクの実装に差し替える、という使い方をして初めて「導入して良かった」と実感できました。

Repositoryパターンは「Eloquentを隠すこと」自体が目的ではなく、「複雑さの集約」と「差し替え可能性」が目的だと理解してから、設計がぐっと楽になりました。

応用:いつ導入すべきかの判断基準

小規模プロジェクトでは不要な場合もある

すべてのプロジェクトにService・Repositoryパターンが必要というわけではありません。

小規模なCRUDアプリで、クエリもシンプルなら、Controllerから直接Eloquentを呼んでも十分に読みやすいコードになります。

導入するかどうかの判断軸としては、以下のような基準が目安になります。

  • 同じクエリロジックが複数箇所で重複している
  • 業務ロジックが複雑で、単体テストを重点的に書きたい
  • 将来的にデータソース(DB以外のAPIなど)が変わる可能性がある

これらに当てはまらないなら、無理にレイヤーを増やさず、シンプルな構成を維持する方が保守しやすいです。

テストのしやすさというメリット

とはいえ、チーム開発や中長期運用のプロジェクトでは、テストのしやすさというメリットが特に効いてきます。

ServiceクラスをコンストラクタでRepositoryのInterfaceに依存させておけば、テスト時にはDBを介さないフェイク実装に差し替えられます。

結果として、ビジネスロジックのテストが高速かつ安定して実行できるようになります。

まとめ

この記事のポイント

  • Controllerから追い出したロジックの受け皿として、Service(業務ロジック)とRepository(データアクセス)の2層に分ける
  • RepositoryはInterfaceを挟み、サービスコンテナでbindすることで差し替え可能にする
  • 「ただEloquentを薄くラップするだけ」では恩恵は薄く、複雑なクエリの集約とテストでの差し替えが本当の価値になる
  • 小規模でシンプルなCRUDなら無理に導入せず、複雑さやテストの必要性に応じて判断する

次に読むべき記事

Service層でリクエストデータを扱う際、バリデーションの置き場所も整理しておきたいところです。

次回は、Form Requestを使ってバリデーションをControllerからさらにきれいに分離する方法を解説します。

→ 次の記事:Form Requestを活用してバリデーションを整理する

タイトルとURLをコピーしました