メインコンテンツまでスキップ

リポジトリパターン — ドメインと永続化の境界

リポジトリとは

アーキテクチャの章で見た「リポジトリインターフェース」について、本章で詳しく解説します。

Eloquent ORMについて

本章ではLaravelのEloquent ORMを使ったリポジトリ実装を解説します。Eloquentの基礎(モデル定義、CRUD操作、リレーション)はChapter 2「Laravel API開発の基礎」で解説しています。

リポジトリパターンの起源

リポジトリパターンは、Martin Fowlerによって「Patterns of Enterprise Application Architecture」で定義されたパターンです。

リポジトリの役割は、ドメインモデルと永続化層(データベース)の間の差を吸収することです。具体的には以下の責務を持ちます。

  • コレクションのような振る舞い: データベースを「オブジェクトのコレクション」として扱える
  • クエリの隠蔽: SQL文や検索ロジックをドメイン層から隠蔽する
  • 永続化メカニズムの抽象化: DBの種類(MySQL、PostgreSQL、MongoDBなど)を気にせずドメインロジックを記述できる

インターフェースをドメイン層に置く理由

重要なのは、リポジトリのインターフェースはドメイン層に属し、実装はインフラ層に属するという点です。

なぜインターフェースをドメイン層に置くのでしょうか。これには明確な理由があります。

理由1:依存性逆転の原則(DIP)

ドメイン層は「ビジネスの本質」であり、技術的な詳細(データベース、フレームワーク)に依存すべきではありません。

矢印の向きは「インフラ層 → ドメイン層」で、依存の方向が逆転しています。

理由2:テスタビリティの向上

ドメイン層のユースケースは、リポジトリインターフェースに依存します。このインターフェースが同じドメイン層にあれば、テスト時にモックを注入しやすくなります。

// テスト時:モックリポジトリを注入
$mockRepository = $this->createMock(OrderRepositoryInterface::class);
$mockRepository->method('findById')->willReturn($testOrder);

// ドメイン層のユースケースはインターフェースに依存
$useCase = new ConfirmOrderUseCase($mockRepository);
$useCase->execute($orderId); // DBなしでビジネスロジックをテスト可能

理由3:ビジネスの言葉で記述

リポジトリインターフェースは「ビジネスが必要とする操作」を表現します。技術的な詳細(SQL、Eloquent)ではなく、ビジネスの言葉で記述されるべきです。

// ドメイン層のインターフェース:ビジネスの言葉
interface OrderRepositoryInterface
{
public function findById(OrderId $id): ?Order;
public function save(Order $order): void;
public function nextIdentity(): OrderId;
}

// インフラ層の実装:技術的な詳細
class EloquentOrderRepository implements OrderRepositoryInterface
{
public function findById(OrderId $id): ?Order
{
// Eloquent、SQL、クエリ最適化などの技術的詳細
$model = OrderModel::with('orderLines')->find($id->value());
return $model ? $this->toEntity($model) : null;
}
}

1集約 = 1リポジトリ

DDDでは、1つの集約に対して1つのリポジトリを作成します。

考え方正しい?理由
OrderRepository + OrderLineRepository を別々に作るNG集約の整合性が壊れるリスク
OrderRepository が orders と order_lines 両方を扱うOK集約は常に一緒に保存されるべき

リポジトリインターフェース

ドメイン層にインターフェースを定義します。

// app/Domain/Order/OrderRepositoryInterface.php

// リポジトリインターフェース: ドメイン層に配置
// 実装の詳細(Eloquent, DB)を知らない。集約の保存・取得の「契約」のみを定義
interface OrderRepositoryInterface
{
/**
* IDで注文を検索する
* @return Order|null 見つからない場合はnullを返す(例外ではない)
*/
public function findById(OrderId $id): ?Order;

/**
* 注文を永続化する(新規作成・更新の両方に対応)
* 集約全体(Order + OrderLine)を一括で保存
*/
public function save(Order $order): void;

/**
* 新しい注文IDを発番する
* DB採番、UUID生成など実装は隠蔽される
*/
public function nextIdentity(): OrderId;

/**
* 新しい注文明細IDを発番する
*/
public function nextLineIdentity(): OrderLineId;
}

リポジトリ実装

インフラ層に実装を配置します。

// app/Infrastructure/Repository/EloquentOrderRepository.php

// リポジトリ実装クラス: インフラ層に配置
// Eloquent ORMを使ってドメインインターフェースを実装
final class EloquentOrderRepository implements OrderRepositoryInterface
{
/**
* IDで注文を検索
*/
public function findById(OrderId $id): ?Order
{
// with('orderLines'): Eager LoadingでN+1問題を回避(詳細は後述)
// find(): 主キーで検索。見つからない場合はnullを返す
$model = OrderModel::with('orderLines')->find($id->value());

// 三項演算子: モデルが存在すればエンティティに変換、なければnull
return $model ? $this->toEntity($model) : null;
}

/**
* 注文を永続化(新規・更新の両方に対応)
*/
public function save(Order $order): void
{
// DB::transaction(): 1集約の保存はリポジトリ内でトランザクション管理
// ordersとorder_linesを同時に保存するため、整合性を保証
DB::transaction(function () use ($order) {
// updateOrCreate(): 存在すれば更新、なければ新規作成
// 第1引数: 検索条件(ユニークキー)
// 第2引数: 保存するデータ
$orderModel = OrderModel::updateOrCreate(
['id' => $order->id()->value()],
[
'status' => $order->status()->value,
// 値オブジェクトから各フィールドを取り出してカラムに保存
'shipping_prefecture' => $order->shippingAddress()->prefecture(),
'shipping_city' => $order->shippingAddress()->city(),
'shipping_street' => $order->shippingAddress()->street(),
]
);

// 関連する注文明細も同時に保存
$this->saveOrderLines($orderModel, $order->orderLines());
});
}

/**
* Eloquentモデル → ドメインエンティティへの変換(マッピング)
* DBの構造(テーブル・カラム)とドメインモデルの差を吸収する重要なメソッド
*/
private function toEntity(OrderModel $model): Order
{
// Eloquent Collection → 配列への変換
// map(): 各要素を変換する Laravel Collection のメソッド
// fn($line) => ...: PHP 8.0+ のアロー関数(短い無名関数)
$orderLines = $model->orderLines->map(fn($line) => new OrderLine(
new OrderLineId($line->id),
new ProductId($line->product_id), // DBカラム名(スネークケース)
$line->quantity,
new Money($line->unit_price, 'JPY'),
))->toArray();

// reconstruct(): DBから復元する専用のファクトリメソッド
// create()は新規作成用、reconstruct()は復元用という使い分け
return Order::reconstruct(
new OrderId($model->id),
// from(): DBの文字列をenumに変換(PHP 8.1+)。
// DBに想定外の値が格納されていた場合は ValueError を投げる。
// 「不正な状態の注文」を復元するよりフェイルファストで止める方が安全
OrderStatus::from($model->status),
new ShippingAddress(
$model->shipping_prefecture,
$model->shipping_city,
$model->shipping_street
),
$orderLines,
);
}

// nextIdentity(), nextLineIdentity(), saveOrderLines() の実装は
// [第19章「実践:注文システムの実装」](./19-practice-order-system.md) で示します。
}

N+1問題とEager Loading

リポジトリ実装で OrderModel::with('orderLines') を使用しています。これはN+1問題を回避するためです。

N+1問題とは

N+1問題は、ORMを使う際に最も頻発するパフォーマンス問題の1つです。初学者が必ず遭遇する問題なので、詳しく理解しましょう。

問題の本質

「親」のデータを取得した後、その「子」のデータを1件ずつ取得してしまい、大量のクエリが発行される問題です。

[N+1問題の発生パターン]

1. 親データを取得(1回のクエリ)
クエリ1: SELECT * FROM orders LIMIT 10 -- 10件の注文を取得

2. 各親データに対して子データを取得(N回のクエリ)
クエリ2: SELECT * FROM order_lines WHERE order_id = 1
クエリ3: SELECT * FROM order_lines WHERE order_id = 2
クエリ4: SELECT * FROM order_lines WHERE order_id = 3
...
クエリ11: SELECT * FROM order_lines WHERE order_id = 10

合計: 1 + N = 11クエリ(Nは親データの件数)

注文が10件なら11回、100件なら101回、1000件なら1001回のクエリが発行されます。

なぜパフォーマンス問題になるのか

  • ネットワーク往復: 各クエリごとにアプリケーション ↔ DB間の通信が発生
  • クエリ実行オーバーヘッド: クエリのパース、実行計画の作成が毎回発生
  • スケールしない: データ件数が増えると線形的にクエリ数が増加

1000 件の注文を取得する場合を例に挙げます。

  • N+1問題あり: 1001回のクエリ → 数秒〜数十秒かかる可能性
  • Eager Loading: 2回のクエリ → 0.1秒以下で完了

問題のあるコード

// NG: N+1問題が発生
public function findById(OrderId $id): ?Order
{
$model = OrderModel::find($id->value()); // 1クエリ
if (!$model) {
return null;
}

// $model->orderLines にアクセスするたびに追加クエリが発行される
$orderLines = $model->orderLines; // +1クエリ
// ...
}

// 一覧取得でさらに深刻
public function findAll(): array
{
$models = OrderModel::all(); // 1クエリ

return $models->map(function ($model) {
// 各注文ごとにorder_linesを取得 → N回のクエリ
return $this->toEntity($model);
})->toArray();
}

Eager Loading vs Lazy Loading

EloquentのリレーションにはLazy LoadingとEager Loadingの2つのアプローチがあります。

Lazy Loading(遅延ロード)

リレーションデータは、実際にアクセスされた時に初めて読み込まれます。

// N件の注文を取得(1クエリ)
$orders = OrderModel::all(); // SELECT * FROM orders

foreach ($orders as $order) {
// 各注文の orderLines アクセス時にクエリが発行される → N回のクエリ
foreach ($order->orderLines as $line) { // +N回: SELECT * FROM order_lines WHERE order_id = ?
echo $line->quantity;
}
}
// 合計 1 + N クエリ(N+1問題)

メリット: 必要なデータだけを読み込めます。 デメリット: ループ内で使うと N+1 問題が発生します。

Eager Loading(事前ロード)

リレーションデータを親データと一緒に事前に読み込みます。

$orders = OrderModel::with('orderLines')->get();
// クエリ1: SELECT * FROM orders
// クエリ2: SELECT * FROM order_lines WHERE order_id IN (1,2,3,...)

foreach ($orders as $order) {
foreach ($order->orderLines as $line) { // 追加クエリなし
echo $line->quantity;
}
}

メリット: クエリ数が一定で、N+1 問題が発生しません。 デメリット: 不要なデータも読み込む可能性があります。

Eager Loadingによる解決

// OK: Eager Loadingで1+1=2クエリに最適化
public function findById(OrderId $id): ?Order
{
$model = OrderModel::with('orderLines')->find($id->value());
// クエリ1: SELECT * FROM orders WHERE id = ?
// クエリ2: SELECT * FROM order_lines WHERE order_id IN (?)

return $model ? $this->toEntity($model) : null;
}

// 一覧取得も2クエリで完了
public function findAll(): array
{
$models = OrderModel::with('orderLines')->get();
// クエリ1: SELECT * FROM orders
// クエリ2: SELECT * FROM order_lines WHERE order_id IN (1,2,3,...)

return $models->map(fn($model) => $this->toEntity($model))->toArray();
}

ネストしたリレーションのEager Loading

リレーションが複数階層ある場合は、ドット記法で指定します。

// 注文 → 注文明細 → 商品 の3階層をEager Loading
$orders = OrderModel::with('orderLines.product')->get();
// クエリ1: SELECT * FROM orders
// クエリ2: SELECT * FROM order_lines WHERE order_id IN (...)
// クエリ3: SELECT * FROM products WHERE id IN (...)

// 3クエリで完了(N+1問題なし)

リポジトリでのベストプラクティス

パターン方法使用場面
単一取得with('relation')->find($id)集約全体を復元する場合
一覧取得with('relation')->get()複数の集約を取得する場合
必要な関連のみwith(['relation1', 'relation2'])複数の関連がある場合
N+1問題の検出と対策ツール

N+1問題は気づかないうちに紛れ込むことが多いため、開発時から検出ツールを活用しましょう。

開発環境での検出

  1. Laravel Debugbar: 画面下部にクエリ数と実行時間を表示
  2. DB::enableQueryLog(): 実行されたクエリをログに記録
DB::enableQueryLog();

// コードを実行

$queries = DB::getQueryLog();
dump(count($queries), $queries); // クエリ数と詳細を出力

本番環境での監視

Laravel Telescopeを使うと、本番環境でもクエリを監視できます。

  • クエリの実行時間
  • N+1問題の自動検出
  • スロークエリの特定

参考リンク

Eloquent Model

インフラ層でEloquent Modelを定義します。

// app/Infrastructure/Eloquent/OrderModel.php
final class OrderModel extends Model
{
protected $table = 'orders';
protected $fillable = ['status', 'shipping_prefecture', 'shipping_city', 'shipping_street'];

public function orderLines(): HasMany
{
return $this->hasMany(OrderLineModel::class, 'order_id');
}
}

// app/Infrastructure/Eloquent/OrderLineModel.php
final class OrderLineModel extends Model
{
protected $table = 'order_lines';
protected $fillable = ['order_id', 'product_id', 'quantity', 'unit_price'];
}

Mass Assignment対策

$fillableプロパティはMass Assignment攻撃を防ぐための重要なセキュリティ機能です。

// 危険:すべての属性が一括代入可能
$order = OrderModel::create($request->all()); // 悪意あるデータでidやcreated_atを上書き可能

// 安全:$fillableで指定したカラムのみ代入可能
$order = OrderModel::create([
'status' => 'draft',
'shipping_prefecture' => $address->prefecture(),
// idやcreated_atは$fillableに含まれないため無視される
]);
プロパティ説明推奨
$fillable代入可能なカラムを指定(ホワイトリスト)推奨
$guarded代入禁止のカラムを指定(ブラックリスト)非推奨(カラム追加時に指定漏れで脆弱性を生むリスク)
Eloquent ORMのセキュリティ

Eloquentのwhere()find()メソッドは内部でプリペアドステートメントを使用しており、SQLインジェクション攻撃から保護されています。ただし、DB::raw()whereRaw()を使う場合は注意が必要です。

// 安全:Eloquentのメソッドはパラメータバインディングを使用
$model = OrderModel::where('status', $userInput)->first();

// 注意:生のSQLを使う場合はパラメータバインディングを明示
$model = OrderModel::whereRaw('status = ?', [$userInput])->first(); // OK
$model = OrderModel::whereRaw("status = '$userInput'")->first(); // NG: SQLインジェクションの危険

ServiceProvider(DIコンテナへの登録)

インターフェースと実装クラスのバインディングを登録します。

// app/Infrastructure/Provider/RepositoryServiceProvider.php
final class RepositoryServiceProvider extends ServiceProvider
{
// $bindings: 解決時に毎回新規インスタンスを生成する(bind() 相当)。
// Repositoryはステートレスなので通常はbindで十分。
// リクエスト内でインスタンスを共有したい場合は $singletons を使う。
public array $bindings = [
OrderRepositoryInterface::class => EloquentOrderRepository::class,
];
}

config/app.phpproviders 配列に登録することで、DIコンテナが自動的に依存関係を解決します。

リポジトリの責務

リポジトリは以下の責務を持ちます。

  1. ドメインエンティティの永続化: 集約をテーブルに保存
  2. ドメインエンティティの復元: テーブルから集約を再構築
  3. マッピングの詳細を隠蔽: ドメイン層はテーブル構造を知らなくてよい
  4. トランザクション管理: 集約内の整合性を保証(1集約の場合)

リポジトリを使うメリット

メリット説明
テスト容易性リポジトリをモックして、DBなしでテスト可能
柔軟性DBを変更してもドメイン層に影響しない
関心の分離永続化の詳細をインフラ層に隠蔽
// テスト時はモックを注入
$mockRepository = $this->createMock(OrderRepositoryInterface::class);
$mockRepository->method('findById')->willReturn($testOrder);

$useCase = new ConfirmOrderUseCase($mockRepository);
$useCase->execute($orderId); // DBなしでテスト可能

参考リソース

リポジトリパターンとLaravelでの実装について、さらに深く学びたい方は以下のリソースを参照してください。

リポジトリパターンの理論

Laravel実装の詳細

次のチャプターでは、ドメインモデルとテーブル設計の関係について見ていきます。