MENU

blog
スタッフブログ

dot
MVC+Service+Repositoryの概念
技術

MVC+Service+Repositoryの概念

こんにちは、クリエイティブSecの長谷川です。
今回は今更感もあるのですが、表題の通りMVCモデルにServiceとRepositoryの概念が加わったものについて触れたいと思います。

そもそもMVCとは

プログラムの設計思想において、MVCモデルと呼ばれるものがあります。
すでによく知られている概念なので、今更どうでも良いという方は
ここまで読み飛ばしてしまっていいです。

MVCはそれぞれ、「Model」「View」「Controller」の頭文字を取ったものであり
プログラムの役割ごとにコードを分けて管理するものです。
PHPのLaravelなどをはじめ、すでに多くのフレームワークではこの概念が取り入れられていると思います。

Modelの役割

モデルはアプリケーションのデータやビジネスロジックを担当します。
具体的には、データの取得・保存・変更・削除、データのバリデーション、データの加工や変換などを行います。
モデルはアプリケーションの状態を表現し、データの永続化やデータへのアクセスを管理します。

Viewの役割

ビューはユーザーインターフェース(UI)を担当します。
具体的には、データの表示やユーザーからの入力の受け付け、ユーザーへの情報提供などを行います。
ビューはユーザーが操作するためのインタラクティブな要素を持ち、ユーザーに情報を提示するための視覚的な要素を構築します。

Controllerの役割

コントローラーはユーザーからの入力を受け取り、適切なモデルの処理やビューの表示を制御します。
具体的には、ユーザーの要求に基づいてモデルのデータを操作し、ビューの更新や表示をトリガーします。
コントローラーはユーザーとアプリケーションの間の仲介役として機能し、要求された操作の実行を調整します。

ServiceとRepositoryについて

では、今回出てくるServiceとRepositoryの役割は何なのかというと以下になります。

Serviceの役割

サービスは、ビジネスロジックやアプリケーションの振る舞いを抽象化し
モデルやコントローラーから分離するためのコンポーネントです。
サービスは、データの取得、加工、バリデーション、外部サービスとの連携など、ビジネスロジックの具体的な処理を担当します。
これにより、コントローラーやモデルが煩雑になるのを防ぎ、コードの再利用性や保守性を向上させます。

サービスを使用することで、ビジネスロジックの共通化や複雑な操作の抽象化が容易になります。
また、サービスはビジネスドメインの概念や要件に近い形でコードを構造化することができます。

Repositoryの役割

リポジトリは、データの永続化やデータソースとのやり取りを抽象化するためのコンポーネントです。
リポジトリは、データの取得、保存、更新、削除などの操作を提供し、データベースや外部データソースとの通信を隠蔽します。
これにより、モデルやサービスからデータアクセスの詳細を分離し、データアクセスの切り替えやテスト容易性の向上を実現します。

リポジトリは、データソースの抽象化により、データベースやキャッシュ、外部APIなどの
異なるデータソースに対して一貫したインターフェースを提供します。
これにより、データの変更やデータソースの切り替えが容易になり、アプリケーションの拡張性と保守性が向上します。

ServiceとRepositoryの導入で何が変わるのか

ServiceとRepositoryの導入は、MVCのアーキテクチャパターンをより柔軟にし
ビジネスロジックとデータアクセスの責任を適切に分離することで、コードの保守性、拡張性、テスト容易性を向上させることを目指しています。

先程のMVCのそれぞれの役割では、Modelが「データの取得・保存・変更・削除、データのバリデーション、データの加工や変換などを行います」と書きました。
しかし、Repositoryが加わることで、Modelからは上記の役割画は外れ、データのバリデーションや変換・加工、ビジネスロジックの実行など、データに関連する処理を担当することになります。

実例で見るMVCとMVC+Service+Repositoryの違い

言葉でいろいろ述べても違いが分かりづらいような気もしますので
Laravelフレームワークで簡単な例を紹介したいと思います。

例の内容としては、ユーザーモデルとそれを取得するためのコントローラー、結果を表示するためのビューがあるというパターンです。
なお、ビューはあまり説明がいらないので省略します。

MVCのみの例

ユーザーモデル
<?php

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, HasFactory, Notifiable;

    /**
     * The attributes that are mass assignable.
     *
     * @var array<int, string>
     */
    protected $fillable = [
        'first_name',
        'last_name',
        'email',
        'password',
    ];

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var array<int, string>
     */
    protected $hidden = [
        'password',
        'remember_token',
    ];

    /**
     * The attributes that should be cast.
     *
     * @var array<string, string>
     */
    protected $casts = [
        'email_verified_at' => 'datetime',
        'password' => 'hashed',
    ];

    /**
     * Search for users by name.
     */
    public static function searchByName($name = '')
    {
        return self::where('first_name', 'like', "%$name%")
            ->orWhere('last_name', 'like', "%$name%");
    }

    /**
     * Get the user's full name.
     * @return string
     */
    public function getFullNameAttribute(): string
    {
        return "{$this->first_name} {$this->last_name}";
    }
}

ちょっとソースが長めですが、Laravelにデフォルトで生成されるモデルからの変更点として以下を追加しています。

  • name属性をfirst_nameとlast_nameに分ける
  • searchByName()メソッドの追加
  • 姓と名を結合したものを返すgetFullNameAttribute()メソッドの追加
ユーザーコントローラー
<?php
namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Models\User;

class UserController extends Controller
{
    public function index(Request $request)
    {
        $search = $request->query('search');
        $users = User::where('first_name', 'like', "%$search%")
            ->orWhere('last_name', 'like', "%$search%")
            ->paginate(10);

        return view('user.index', compact('users', 'search'));
    }
}

ユーザーコントローラーでは以下のことをやっています。

  • リクエストでsearchパラメータを取得する
  • ユーザーモデルから姓または名に対してキーワードに一致するものを取得する
  • 検索結果をビューに返す

先程、ユーザーモデルにもfindByName()という名前で検索するメソッドを書きましたが
プロジェクトの規模が大きく、且つ複数人でプロジェクトを実行していると
汎用的な検索ロジックはモデルに実装されているが、そうでないものはControllerにかかれていたりすることもあるため、敢えて重複したものを書いています。

上記のMVCの例では、例が少ないコードなのでちょっとイメージが湧きづらいかもしれませんが、このような感じで規模が大きくなってくると
検索ロジックがいろんなところに書かれたり、コントローラーが肥大化したりと言った現象が発生しやすくなります。

また、同一の検索ロジックなどが複数のコントローラーやモデルで記載された場合
何かそのロジック自体に修正が入ったときは、あちらこちらで直さなければなりません。

MVC+Service+Repositoryの場合

では、先程のソースコードをServiceとRepositoryに分けます。
まず、Laravelでは標準ではServicesやRepositoriesといったディレクトリが無いのでappディレクトリ以下に作成します。

次にRepositoriesフォルダ以下にユーザーに関するリポジトリとそのインタフェースを作成します。

ユーザーリポジトリインタフェース
<?php
namespace App\Repositories;

use Illuminate\Database\Eloquent\Builder;

interface UserRepositoryInterface
{
    public function findByName(?string $search): Builder;
}
ユーザーリポジトリ
<?php
namespace App\Repositories;

use App\Models\User;
use Illuminate\Database\Eloquent\Builder;

class UserRepository implements UserRepositoryInterface
{
    public function findByName(?string $search): Builder
    {
        return User::where('first_name', 'like', "%$search%")
            ->orWhere('last_name', 'like', "%$search%");
    }
}

こんな感じで、先程のMVCパターンのときにモデルにあったfindByNameメソッドを持ってきました。
今回はfindByName()メソッドではBuilderクラスを返すようにしていますが、Repositoryにおいて、特にこの型で返さないといけないというのは決まっていないようです。
なので、場合によってはCollectionだったりEloquentインスタンスでも問題ありません。

では、次にServicesディレクトリ以下にユーザーに関するサービスとそのインタフェースを作成します。

ユーザーサービスインタフェース
<?php
namespace App\Services;

use Illuminate\Contracts\Pagination\LengthAwarePaginator;

interface UserServiceInterface
{
    public function findByName(?string $search, int $perPage = 10): LengthAwarePaginator;
}
ユーザーサービス
<?php
namespace App\Services;

use App\Repositories\UserRepositoryInterface;
use Illuminate\Pagination\LengthAwarePaginator;

class UserService implements UserServiceInterface
{
    private $userRepository;

    public function __construct(UserRepositoryInterface $userRepository)
    {
        $this->userRepository = $userRepository;
    }
    
    public function findByName(?string $search, int $perPage = 10): LengthAwarePaginator
    {
        return $this->userRepository->findByName($search)->paginate($perPage);
    }
}

サービスでは、先程作成したリポジトリのfindByName()メソッドを呼び出していますが、コントローラ側から受け取るパラメータを受け渡しています。
また、今回の例ではコントローラ側でページネーションしているので、リポジトリから返却されたBuilderインスタンスに対して、ページネーションして返すようにしています。

よくある、セレクト用のIDと名前だけの配列を返すために、クエリの結果からpluck()して取ってくるなんてこともありますが
そういった処理も同じようにサービス側で実装してあげると良いと思います。

ユーザーモデル
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, HasFactory, Notifiable;

    /**
     * The attributes that are mass assignable.
     *
     * @var array<int, string>
     */
    protected $fillable = [
        'first_name',
        'last_name',
        'email',
        'password',
    ];

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var array<int, string>
     */
    protected $hidden = [
        'password',
        'remember_token',
    ];

    /**
     * The attributes that should be cast.
     *
     * @var array<string, string>
     */
    protected $casts = [
        'email_verified_at' => 'datetime',
        'password' => 'hashed',
    ];

    /**
     * Get the user's full name.
     * @return string
     */
    public function getFullNameAttribute(): string
    {
        return "{$this->first_name} {$this->last_name}";
    }
}

リポジトリにfindByName()を作成したので、モデルにはfindByName()は不要となりました。
モデルにはビジネスロジックやフィールドの定義などだけを残しておきます。

では、最後にコントーラーですが、その前に作成したサービスを使うために、Laravelのプロパイダーに登録しておきます。
こちらのファイルはLaravelに最初から存在していると思います。

サービスプロバイダー
<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        // 以下を追加
        $this->app->singleton(
            \App\Repositories\UserRepositoryInterface::class,
            \App\Repositories\UserRepository::class
        );
    }

最後にコントローラーです・

ユーザーコントローラ
<?php
namespace App\Http\Controllers;

use App\Services\UserService;
use Illuminate\Http\Request;

class UserController extends Controller
{

    private $userService; // ←プライベート変数定義

    /**
     * コンストラクタで使用するサービスを変数に格納します
     */
    public function __construct(UserService $userService)
    {
        $this->userService = $userService;
    }

    public function index(Request $request)
    {
        $search = $request->query('search');
        $perPage = $request->query('per_page', 10);
        $users = $this->userService->findByName($search, $perPage); // ← サービスのメソッド実行
        return view('user.index', compact('users', 'search'));
    }
}

ソースコード内にコメント追記してありますが、サービスを使うために以下を行っています。

  • コントローラ内で利用するサービスインスタンスを格納する変数の定義
  • コンストラクタでサービスインスタンスの受け取りと変数格納

あとは、indexアクション内で受け取ったリクエストパラメータから
必要な値を取り出した上で、サービスのメソッドを呼ぶだけですね。

必ずしもServiceとRepositoryを使う必要はない

さて、長々と書いておいてなんですが、先程の例のように
プロジェクトの規模が小さい場合は、むしろファイル数もコードの量も増えてしまいます。
そのため、小規模案件ではわざわざServiceとRepositoryに分けなくて良いかもしれません。

しかし、大規模なプロジェクトなどになってくると、ServiceとRepositoryの概念を取り込んだほうが
コードの再利用やメンテナンス性などでのメリットが大きくなってくると思いますので
積極的に活用していってもいいのではないでしょうか。

さいごに

いかがでしたでしょうか?
今回はいつにもまして長文になってしまいました。
ここまでお読みいただいた方、本当にありがとうございます。

MVCという概念はフレームワークなどで最初から取り入れられており
すでに幅広く知られているかと思いますが、ServiceとRepositoryの概念については
身の回りでまだ知らないという人も割といましたので、今回紹介させていただきました。

この記事が何かしらのお役に立てれば幸いです。
それでは今回はこの辺で。

dot
dot
PAGETOP