リポジトリパターン — ドメインと永続化の境界
リポジトリとは
アーキテクチャの章で見た「リポジトリインターフェース」について、本章で詳しく解説します。
本章では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問題は気づかないうちに紛れ込むことが多いため、開発時から検出ツールを活用しましょう。
開発環境での検出
- Laravel Debugbar: 画面下部にクエリ数と実行時間を表示
- DB::enableQueryLog(): 実行されたクエリをログに記録
DB::enableQueryLog();
// コードを実行
$queries = DB::getQueryLog();
dump(count($queries), $queries); // クエリ数と詳細を出力
本番環境での監視
Laravel Telescopeを使うと、本番環境でもクエリを監視できます。
- クエリの実行時間
- N+1問題の自動検出
- スロークエリの特定
参考リンク
- Laravel Eloquent Relationships - リレーションとEager Loadingの公式ドキュメント
- Laravel Telescope - クエリ監視ツール
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の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.php の providers 配列に登録することで、DIコンテナが自動的に依存関係を解決します。
リポジトリの責務
リポジトリは以下の責務を持ちます。
- ドメインエンティティの永続化: 集約をテーブルに保存
- ドメインエンティティの復元: テーブルから集約を再構築
- マッピングの詳細を隠蔽: ドメイン層はテーブル構造を知らなくてよい
- トランザクション管理: 集約内の整合性を保証(1集約の場合)
リポジトリを使うメリット
| メリット | 説明 |
|---|---|
| テスト容易性 | リポジトリをモックして、DBなしでテスト可能 |
| 柔軟性 | DBを変更してもドメイン層に影響しない |
| 関心の分離 | 永続化の詳細をインフラ層に隠蔽 |
// テスト時はモックを注入
$mockRepository = $this->createMock(OrderRepositoryInterface::class);
$mockRepository->method('findById')->willReturn($testOrder);
$useCase = new ConfirmOrderUseCase($mockRepository);
$useCase->execute($orderId); // DBなしでテスト可能
参考リソース
リポジトリパターンとLaravelでの実装について、さらに深く学びたい方は以下のリソースを参照してください。
リポジトリパターンの理論
- Martin Fowler - Repository Pattern - リポジトリパターンの原典
Laravel実装の詳細
- Laravel Eloquent Relationships - リレーションとEager Loadingの公式ドキュメント
- Laravel Telescope - N+1問題を検出する開発ツール
次のチャプターでは、ドメインモデルとテーブル設計の関係について見ていきます。