概要
Laravelでのチーム開発において、ビジネスロジックを整理し、テスト可能なコードを実現するために「サービス・リポジトリパターン」を採用する方法を解説します。本記事では、設計思想の説明から、具体的なコード例、そしてテストコードの記述方法までを詳しく解説し、保守性と品質の高いアプリケーションを構築する手法を紹介します。
目的
この記事の目的は、Laravelでサービス・リポジトリパターンを導入し、テストコードも含めた高品質なアプリケーションを構築する方法を学ぶことです。
具体的には以下を達成します:
-
サービス・リポジトリパターンの導入理由を理解
- コードの保守性・拡張性を高めるために、このパターンがどのように役立つかを解説します。
-
実践的な実装方法を学ぶ
- 実際のLaravelプロジェクトでサービスとリポジトリを構築し、コントローラからビジネスロジックを分離する方法を解説します。
-
テストコードの導入
- テスト駆動開発(TDD)の考え方を取り入れ、リポジトリやサービスレイヤーを効率的にテストする方法を学びます。
この記事を通じて、開発の効率化とコード品質の向上を目指します。特に、複数人でのチーム開発や中・大規模プロジェクトにおいて、その効果を実感できるでしょう。
サービス・レポジトリパターンとは?
サービス・レポジトリパターンは、Laravelや他のフレームワークで広く使用される設計パターンで、以下の役割を分離して管理することを目的としています:
-
リポジトリ(Repository)
- データベース操作を抽象化し、モデル(Eloquent)との直接的な依存を避けます。
- データの取得・保存など、データ層に関する責務を持ちます。
-
サービス(Service)
- ビジネスロジックを実装し、コントローラからその責務を分離します。
- 複数のリポジトリや外部APIを統合し、アプリケーションの振る舞いを管理します。
本記事での目的
本記事でサービス・レポジトリパターンを導入する目的は、単に個人が学ぶだけではなく、以下のようなチーム開発全体の効率化を目指すことにあります:
書き方の指針をチームで共有する
- サービス・レポジトリパターンの基本的な構造を記事で示すことで、チーム全体が同じ設計基準に基づいて開発を進められるようになります。
- 新しくチームに加わったメンバーも、この指針を参考にすることでスムーズに開発に参加できます。
保守性と統一感の向上
- パターンを適用することで、コードが一貫性を持ち、他のメンバーも容易に理解・修正が可能となります。
テスト容易性の確保
- リポジトリやサービスごとにテストコードを書くことで、チーム全体で高品質なコードベースを維持できます。
補足
システム開発の初期段階では、「このシステムがどこまで大きくなるか」や「何が必要になるか」を正確に予測するのは困難です。そのため、特に明確なコーディングルールがない状態で開発が始まることが多いでしょう。
また開発が進むにつれて保守性が必要になってきたときに「疎結合で保守性が高い設計にしよう!」と伝えても、実際にはメンバーそれぞれの解釈やこだわりが影響し、次のような問題が発生しがちです:
-
ディレクトリ構成の違い
チームメンバーごとにファイルの配置がバラバラになり、管理が煩雑化する。 -
命名規則のばらつき
変数名やクラス名の命名規則が統一されておらず、可読性が低下する。 -
設計基準の不明瞭さ
どこまでを1つの「セット」として設計するのかが明確でないため、コードの見通しが悪くなる。
そこで、本記事ではこうした問題を解決するための一つの指針を提供したいと考えました。
この指針を通じて、チーム全体で統一感を持ちながら、保守性が高く、将来的な拡張にも柔軟に対応できるシステムを構築する足がかりを作ることが目的です。
これにより、開発チームが共通のルールを持ち、効率的で質の高い開発を進められることを目指しています。
導入のメリット
-
コード品質の向上
データ操作とビジネスロジックを分離することで、コードが読みやすくなり、管理が容易になります。 -
拡張性の確保
パターンをチーム全体で導入することで、新機能追加や修正が容易になります。 -
学習コストの低減
一度書き方の基準を作ることで、新しいメンバーや初心者も同じ基準で学び、効率よくプロジェクトに貢献できるようになります。
適用例
例えば、ユーザー管理機能を実装する場合:
- リポジトリ: ユーザーのデータ操作(検索、作成、更新、削除)を管理。
- サービス: ユーザーの認証やロール設定、通知処理などのビジネスロジックを管理。
このように役割を明確に分けることで、開発スピードと保守性の向上が期待できます。
前提条件
この記事を進めるにあたり、以下の条件が満たされていることを確認してください:
-
Laravelプロジェクトがセットアップ済みであること
- Laravel 11を使用したプロジェクトが構築されており、動作確認ができていること。
-
基本的な開発環境が整っていること
- Dockerまたはローカル環境でPHP 8.4、MySQL、Composerが動作していること。
- Laravelプロジェクト内で必要なコンポーザパッケージがインストールされていること。
-
この記事までのシリーズ記事を完了していること(推奨)
これらの前提条件を満たすことで、この記事の手順をスムーズに進めることができます。
具体的な手順
サービス・レポジトリパターンをLaravelに導入する具体的な手順を以下に示します。各セクションでは、責務の明確化やテスト可能なコードを書くためのポイントを解説しています。
構成
以下のようなディレクトリ構成を作成または更新します。
Service/Repository
には、Contractsディレクトリにインターフェースを配置し、Implementationsディレクトリに実装クラスを配置します。
src
├─ app
│ ├─ Http
│ │ └─ Controllers
│ │ └─ SampleController.php
│ ├─ Models
│ │ └─ Sample.php
│ ├─ Providers
│ │ └─ SampleProvider.php
│ ├─ Repositories
│ │ ├─ Contracts
│ │ │ └─ SampleRepositoryInterface.php
│ │ └─ Implementations
│ │ └─ SampleRepository.php
│ └─ Services
│ ├─ Contracts
│ │ └─ SampleServiceInterface.php
│ └─ Implementations
│ └─ SampleService.php
├─ bootstrap
│ └─ providers.php
├─ database
│ ├─ factories
│ │ └─ SampleFactory.php
│ ├─ migrations
│ │ └─ 2025_01_03_143135_create_samples_table.php
│ └─ seeders
│ └─ SampleSeeder.php
└─ tests
├─ Feature
│ └─ SampleControllerTest.php
└─ Unit
└─ SampleServiceTest.php
補足
-
ContractsとImplementationsの分離:
この構成により、インターフェースと実装を分離し、依存注入(DI)を簡単に切り替え可能にします。Mockを利用したテストが容易になります。 -
テストコードの配置:
FeatureテストとUnitテストを分けて配置することで、テストの粒度を明確にしています。
Controller
コントローラはアプリケーションの受け口を担当します。
業務ロジックは持たせず、サービスを呼び出すシンプルな設計にします。
app/Http/Controllers/SampleController.php
<?php
namespace App\Http\Controllers;
use Inertia\Inertia;
use Inertia\Response;
use App\Services\Contracts\SampleServiceInterface;
class SampleController extends Controller
{
function __construct(
private readonly SampleServiceInterface $sampleService
)
{}
public function sample(): Response
{
$sample = $this->sampleService->getSample(1);
return Inertia::render('SamplePage', [
'message' => $sample->sample_str,
]);
}
}
補足
-
サービスのコンストラクタインジェクション:
サービスをDIすることで、Controllerはサービスの利用に専念し、テストも容易になります。 -
簡潔なController:
ロジックを持たず、サービスの呼び出しに特化しています。これにより、変更に強く、テスト可能なコードになります。
Service
サービスはビジネスロジックを担当します。
具体的なデータ操作はリポジトリに任せ、サービスはアプリケーションの業務上のルールを実装します。
app/Services/Contracts/SampleServiceInterface.php
<?php
namespace App\Services\Contracts;
use App\Models\Sample;
interface SampleServiceInterface
{
public function getSample(int $id): Sample;
}
app/Services/Implementations/SampleService.php
<?php
namespace App\Services\Implementations;
use App\Models\Sample;
use App\Services\Contracts\SampleServiceInterface;
use App\Repositories\Contracts\SampleRepositoryInterface;
class SampleService implements SampleServiceInterface
{
public function __construct(
private readonly SampleRepositoryInterface $sampleRepository
){}
public function getSample(int $id): Sample
{
return $this->sampleRepository->findById($id);
}
}
補足
-
リポジトリのコンストラクタインジェクション:
データ操作はリポジトリに委譲し、サービスは業務ロジックのみに集中します。 -
インターフェースで抽象化:
サービスもリポジトリもインターフェースを利用して抽象化し、依存を緩やかに保っています。
Repository
リポジトリはデータアクセスロジックを担当します。
具体的にはデータベース操作や外部APIの呼び出しを抽象化します。
app/Repositories/Contracts/SampleRepositoryInterface.php
<?php
namespace App\Repositories\Contracts;
use App\Models\Sample;
interface SampleRepositoryInterface
{
public function findById(int $id): Sample;
}
app/Repositories/Implementations/SampleRepository.php
<?php
namespace App\Repositories\Implementations;
use App\Models\Sample;
use App\Repositories\Contracts\SampleRepositoryInterface;
class SampleRepository implements SampleRepositoryInterface
{
public function findById(int $id): Sample
{
return Sample::find($id) ?? new Sample(['sample_str' => 'Default String']);
}
}
補足
-
データ操作の責務を分離:
モデル操作をリポジトリに集約することで、データロジックが分散することを防ぎます。 -
初期値の設定:
findByIdでデータが見つからない場合に、デフォルト値を設定しています。
Model
Sample
モデルを作成して、samples
テーブルに紐づけます。
app/Models/Sample.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Database\Factories\SampleFactory;
class Sample extends Model
{
/** @use HasFactory<SampleFactory> */
use HasFactory;
protected $table = 'samples';
protected $fillable = [
'sample_num',
'sample_str',
];
}
補足
-
@use HasFactoryの記述
HasFactoryを正確に指定するための型ヒントを加えています。これはLarastanで問題が指摘されるのを防ぐためです。 - $fillableの指定
フィールドのホワイトリストを設定することで、マスアサインメントの脆弱性を防ぎます。
Provider
サービスコンテナで、サービスとリポジトリをバインドするプロバイダーを作成します。
app/Providers/SampleProvider.php
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use App\Services\Contracts\SampleServiceInterface;
use App\Services\Implementations\SampleService;
use App\Repositories\Contracts\SampleRepositoryInterface;
use App\Repositories\Implementations\SampleRepository;
class SampleProvider extends ServiceProvider
{
/**
* Register services.
*/
public function register(): void
{
$this->app->bind(SampleServiceInterface::class,SampleService::class);
$this->app->bind(SampleRepositoryInterface::class,SampleRepository::class);
}
/**
* Bootstrap services.
*/
public function boot(): void
{
//
}
}
bootstrap/providers.php
<?php
return [
App\Providers\AppServiceProvider::class,
App\Providers\SampleProvider::class,
];
補足
- サービスコンテナのバインド
registerメソッドで、サービスとリポジトリのインターフェースと実装をバインドします。これにより、インターフェースを利用するコードが具体的な実装に依存しなくなります。 - プロバイダーの登録
作成したプロバイダーをbootstrap/providers.phpに登録することで、Laravelが認識できるようにしています。
database
Factory
SampleFactory
を作成して、データ生成用のロジックを定義します。
database/factories/SampleFactory.php
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Sample>
*/
class SampleFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'sample_num' => $this->faker->numberBetween(1, 100),
'sample_str' => $this->faker->name(),
];
}
}
Migration
シンプルなテーブル構造を作成します。
database/migrations/2025_01_01_000000_create_samples_table.php
- id,sample_num,sample_str,タイムスタンプ系をカラムとしたシンプルなテーブルを作る
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('samples', function (Blueprint $table) {
$table->id();
$table->integer('sample_num');
$table->string('sample_str', 255);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('samples');
}
};
Seeder
database/seeders/SampleSeeder.php
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use App\Models\Sample;
class SampleSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
Sample::factory()->create();
}
}
補足
- Factoryの柔軟性
テストや開発時にランダムなデータを簡単に生成できます。 - Seederの利用
初期データを手軽に投入可能で、特にテストデータの準備が効率化されます。
Tests
テストコードでは、それぞれの責務に応じてMockを活用します。
Featureテスト
SampleControllerTest.php
では、サービスをモック化し、コントローラの動作を検証します。
tests/Feature/SampleControllerTest.php
<?php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\Sample;
use App\Services\Contracts\SampleServiceInterface;
use PHPUnit\Framework\Attributes\Test;
class SampleControllerTest extends TestCase
{
#[Test]
public function it_returns_sample_page_with_sample_message():void
{
$sampleData = new Sample([
'id' => 1,
'sample_num' => 123,
'sample_str' => 'Mocked Message',
]);
$this->mock(SampleServiceInterface::class, function ($mock) use ($sampleData) {
$mock->shouldReceive('getSample')
->with(1)
->once()
->andReturn($sampleData);
});
$response = $this->get('/sample');
$response->assertStatus(200);
}
}
Unitテスト
SampleServiceTest.phpでは、リポジトリをモック化し、サービスのビジネスロジックを検証します。
tests/Unit/SampleServiceTest.php
<?php
namespace Tests\Unit;
use Tests\TestCase;
use App\Services\Contracts\SampleServiceInterface;
use App\Repositories\Contracts\SampleRepositoryInterface;
use App\Models\Sample;
use PHPUnit\Framework\Attributes\Test;
class SampleServiceTest extends TestCase
{
#[Test]
public function it_returns_sample_data_by_id():void
{
$sampleData = new Sample([
'id' => 1,
'sample_num' => 123,
'sample_str' => 'Mocked String',
]);
$this->mock(SampleRepositoryInterface::class, function ($mock) use ($sampleData) {
$mock->shouldReceive('findById')
->with(1)
->once()
->andReturn($sampleData);
});
$sampleService = app(SampleServiceInterface::class);
$result = $sampleService->getSample(1);
$this->assertInstanceOf(Sample::class, $result);
$this->assertEquals(123, $result->sample_num);
$this->assertEquals('Mocked String', $result->sample_str);
}
}
補足
- Mockの活用
Featureテストではサービスを、UnitテストではリポジトリをMock化することで、テストの対象を明確化します。 - テストの分離
コントローラの責務とサービスの責務を明確に分離してテストすることで、エラー発生時の切り分けが容易になります。
これで全てのセクションが完成しました!読者が具体的な実装方法を理解しやすいよう、責務やテストポイントを補足することで、より実践的な内容になっています。
確認
以下の手順で、サービス・レポジトリパターンが正しく動作していることを確認します。
コマンド操作
まず、backend-container
に接続します:
$ cd docker
$ docker exec -it backend-container /bin/bash
マイグレーションとSeederの実行
データベースのマイグレーションとSeederを実行します:
$ php artisan migrate:refresh --seed
正常に実行されると、データベースにSampleSeederで作成されたデータが投入されます。
ユニットテストの実行
以下のコマンドでユニットテストを実行し、サービス・レポジトリが期待通りに動作しているか確認します:
$ php artisan test
すべてのテストが成功(緑色)として表示されればOKです。
動作確認
localhost/sampleへのアクセス
ブラウザで以下のURLにアクセスしてください:
まとめ
この記事では、Laravelでサービス・レポジトリパターンを導入し、ビジネスロジックとデータアクセスロジックを分離する方法を解説しました。以下のポイントを押さえることができました:
サービス・レポジトリパターンの導入
- サービスとリポジトリをインターフェースで抽象化し、拡張性と保守性を向上。
- ビジネスロジックとデータ操作ロジックを明確に分離し、コードの見通しを良くしました。
テスト可能な設計
- Mockを活用したFeatureテストとUnitテストにより、それぞれの責務を検証可能。
- テスト駆動開発(TDD)を意識した設計が品質向上に貢献します。
Laravelの柔軟な構成を活用
- プロバイダーでのバインド設定により、依存性注入(DI)を活用。
- SeederやFactoryを用いてテストデータの生成を効率化。
この記事を通じて、チーム開発での統一された設計基準を提供し、プロジェクト全体の生産性とコード品質を向上させる土台を築くことができました。
まとめ
この記事では、Laravelでサービス・レポジトリパターンを導入し、ビジネスロジックとデータアクセスロジックを分離する方法を解説しました。以下のポイントを押さえることができました:
サービス・レポジトリパターンの導入
- サービスとリポジトリをインターフェースで抽象化し、拡張性と保守性を向上。
- ビジネスロジックとデータ操作ロジックを明確に分離し、コードの見通しを良くしました。
テスト可能な設計
- Mockを活用したFeatureテストとUnitテストにより、それぞれの責務を検証可能。
- テスト駆動開発(TDD)を意識した設計が品質向上に貢献します。
Laravelの柔軟な構成を活用
- プロバイダーでのバインド設定により、依存性注入(DI)を活用。
- SeederやFactoryを用いてテストデータの生成を効率化。
この記事を通じて、チーム開発での統一された設計基準を提供し、プロジェクト全体の生産性とコード品質を向上させる土台を築くことができました。
次の記事の紹介
ここまででサービス・レポジトリパターンを導入し、コード設計とテストの基盤が整いました。次の記事では、DevContainerを活用し、VSCodeでPHPStan・ESLint・TypeScriptを統合して静的解析を行い、リアルタイムでエラーを検知する方法を解説します。
次の記事はこちら
DevContainerを使いVSCodeでPHPStan・ESLint・TypeScriptを統合して静的解析をエラー検知する方法
この記事では、以下のポイントを解説予定です:
-
DevContainerの導入
Dockerを利用したVSCode専用の開発環境をセットアップし、チーム全体で統一された環境を構築します。 -
PHPStan・ESLint・TypeScriptの統合
静的解析ツールを連携し、リアルタイムでエラーや警告を検知できる環境を実現します。 -
チーム開発での活用
開発環境の統一により、メンバー間での環境差異をなくし、効率的なコーディングをサポートします。
これにより、ローカル開発環境のセットアップを簡略化しつつ、高いコード品質を維持できる統一された開発フローを実現します。
引き続き良かったら読んでください!
関連記事
- Laravel + Vue3 チーム開発を効率化!統一環境構築と品質向上の完全ガイド
- DockerでPHP 8.4/Nginx + Node 22 + MySQL環境を構築する方法
- Laravel 11 + Inertia.js + Vue 3 + TypeScriptでモダンなフルスタック環境を構築
- Laravel 11 + Vue 3プロジェクトにSassとBootstrap 5を導入する方法
- DevContainerを使いVSCodeでPHPStan・ESLint・TypeScriptを統合して静的解析をエラー検知する方法
- Gitコミット時に自動チェック!HuskyでESLint・PHPStan・ユニットテストを効率化する方法