#08 デザインパターン入門
Repository——データアクセスをビジネスロジックから切り離す
問題: ビジネスロジックにEloquentが散らばる
LaravelのActiveRecordパターン(Eloquent)はシンプルで便利です。しかし、ビジネスロジックの中で直接Eloquentを呼び出すと、問題が生じます。
// 問題のあるコード: ビジネスロジックにEloquentが直接入り込む
class OrderService
{
public function placeOrder(int $userId, array $items): Order
{
// DBアクセスがビジネスロジックに混在
$user = User::where('id', $userId)->where('active', true)->firstOrFail();
// 在庫チェックもEloquent直接呼び出し
foreach ($items as $item) {
$product = Product::find($item['product_id']);
if ($product->stock < $item['quantity']) {
throw new \RuntimeException("在庫不足: {$product->name}");
}
}
// 注文作成
$order = Order::create(['user_id' => $userId, 'total' => 0]);
// ...
return $order;
}
}
この構造では:
- テストが困難:テストするたびに実際のDBが必要
- DBの変更に弱い:MySQL→PostgreSQL移行時に全箇所を修正
- クエリが散在:同じクエリが複数の場所に重複して書かれがち
パターン: Repository
Repositoryパターンは、データアクセスの詳細を「リポジトリ」クラスに隠蔽し、ビジネスロジック側にはコレクション操作のようなシンプルなインターフェースだけを見せるパターンです。
+-----------------------------------+
| UserRepository <<interface>> |
+-----------------------------------+
| + findById(id: int): ?User |
| + findActiveUsers(): array |
| + save(user: User): void |
| + delete(user: User): void |
+-----------------------------------+
△
_________|___________
| |
EloquentUserRepo InMemoryUserRepo
(本番用) (テスト用)
PHP 8.x での実装
まずエンティティクラスとリポジトリインターフェースを定義します。
<?php
// エンティティ(純粋なPHPクラス、フレームワーク非依存)
final class UserEntity
{
public function __construct(
public readonly ?int $id,
public readonly string $name,
public readonly string $email,
public readonly bool $active,
public readonly \DateTimeImmutable $createdAt,
) {}
}
// リポジトリインターフェース
interface UserRepository
{
public function findById(int $id): ?UserEntity;
public function findAllActive(): array;
public function findByEmail(string $email): ?UserEntity;
public function save(UserEntity $user): UserEntity;
public function delete(int $id): void;
}
次にEloquentを使った本番用実装を作ります。
<?php
// Eloquentを使ったリポジトリ実装
class EloquentUserRepository implements UserRepository
{
public function findById(int $id): ?UserEntity
{
$model = \App\Models\User::find($id);
return $model ? $this->toEntity($model) : null;
}
public function findAllActive(): array
{
return \App\Models\User::where('active', true)
->get()
->map(fn($m) => $this->toEntity($m))
->toArray();
}
public function findByEmail(string $email): ?UserEntity
{
$model = \App\Models\User::where('email', $email)->first();
return $model ? $this->toEntity($model) : null;
}
public function save(UserEntity $user): UserEntity
{
$model = $user->id
? \App\Models\User::findOrFail($user->id)
: new \App\Models\User();
$model->fill([
'name' => $user->name,
'email' => $user->email,
'active' => $user->active,
])->save();
return $this->toEntity($model);
}
public function delete(int $id): void
{
\App\Models\User::destroy($id);
}
private function toEntity(\App\Models\User $model): UserEntity
{
return new UserEntity(
id: $model->id,
name: $model->name,
email: $model->email,
active: (bool) $model->active,
createdAt: new \DateTimeImmutable($model->created_at),
);
}
}
ビジネスロジック側の変化
リポジトリを使うと、OrderService はDBの詳細を知らなくて済みます。
<?php
class OrderService
{
public function __construct(
private readonly UserRepository $users,
private readonly ProductRepository $products,
private readonly OrderRepository $orders,
) {}
public function placeOrder(int $userId, array $items): OrderEntity
{
// ビジネスロジックに集中できる
$user = $this->users->findById($userId);
if ($user === null || !$user->active) {
throw new \DomainException("有効なユーザーではありません");
}
foreach ($items as $item) {
$product = $this->products->findById($item['product_id']);
if ($product === null || $product->stock < $item['quantity']) {
throw new \DomainException("在庫不足: {$item['product_id']}");
}
}
$order = new OrderEntity(
id: null,
userId: $userId,
items: $items,
status: 'pending',
);
return $this->orders->save($order);
}
}
Laravelでの実装例
Laravelでは ServiceProvider でリポジトリをバインドします。
// app/Providers/RepositoryServiceProvider.php
namespace App\Providers;
use App\Repositories\UserRepository;
use App\Repositories\EloquentUserRepository;
use Illuminate\Support\ServiceProvider;
class RepositoryServiceProvider extends ServiceProvider
{
public function register(): void
{
// インターフェース → 具体実装をバインド
$this->app->bind(UserRepository::class, EloquentUserRepository::class);
$this->app->bind(ProductRepository::class, EloquentProductRepository::class);
$this->app->bind(OrderRepository::class, EloquentOrderRepository::class);
}
}
コントローラーではDIでリポジトリを受け取ります。
// app/Http/Controllers/OrderController.php
class OrderController extends Controller
{
public function __construct(
private readonly OrderService $orderService,
) {}
public function store(StoreOrderRequest $request): JsonResponse
{
$order = $this->orderService->placeOrder(
userId: auth()->id(),
items: $request->validated('items'),
);
return response()->json($order, 201);
}
}
テスタビリティの向上
リポジトリパターンの最大のメリットがテスタビリティです。テストではインメモリ実装に差し替えます。
<?php
// テスト用のインメモリ実装
class InMemoryUserRepository implements UserRepository
{
private array $users = [];
private int $nextId = 1;
public function findById(int $id): ?UserEntity
{
return $this->users[$id] ?? null;
}
public function findAllActive(): array
{
return array_values(array_filter($this->users, fn($u) => $u->active));
}
public function findByEmail(string $email): ?UserEntity
{
foreach ($this->users as $user) {
if ($user->email === $email) return $user;
}
return null;
}
public function save(UserEntity $user): UserEntity
{
$id = $user->id ?? $this->nextId++;
$saved = new UserEntity($id, $user->name, $user->email, $user->active, $user->createdAt);
$this->users[$id] = $saved;
return $saved;
}
public function delete(int $id): void
{
unset($this->users[$id]);
}
}
// テストコード(DBを使わずに高速にテストできる)
class OrderServiceTest extends TestCase
{
public function testPlaceOrderSuccessfully(): void
{
$userRepo = new InMemoryUserRepository();
$productRepo = new InMemoryProductRepository();
$orderRepo = new InMemoryOrderRepository();
// テストデータをセット
$userRepo->save(new UserEntity(1, 'Alice', 'alice@example.com', true, new \DateTimeImmutable()));
$productRepo->save(new ProductEntity(1, 'PHP本', 3000, 10));
$service = new OrderService($userRepo, $productRepo, $orderRepo);
$order = $service->placeOrder(1, [['product_id' => 1, 'quantity' => 2]]);
$this->assertSame('pending', $order->status);
}
}
なぜActiveRecordだけでは足りないか
ActiveRecordはシンプルで小規模なアプリに最適ですが、以下の場面でRepositoryが力を発揮します。
| 場面 | ActiveRecordの限界 | Repositoryの強み |
|---|---|---|
| ユニットテスト | DBが必要 | インメモリ実装に差し替え可能 |
| DB移行 | 全箇所修正 | リポジトリ実装だけ差し替え |
| 複雑なクエリ | Serviceに散在 | リポジトリに集約 |
| キャッシュ層 | モデルに混ぜ込む | Decoratorと組み合わせやすい |
まとめ
- Repositoryパターンはデータアクセスをインターフェースの後ろに隠す
- ビジネスロジックはDBの詳細を知らなくてよくなり、可読性が上がる
- テストではインメモリ実装に差し替えることでDBなしに高速テストができる
- Laravelではサービスコンテナでインターフェースと実装をバインドして使う
次回はCommandパターンで、操作をオブジェクトとしてカプセル化する方法を解説します。