#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パターンで、操作をオブジェクトとしてカプセル化する方法を解説します。