Repository InterfaceとDTOでEloquentから業務ロジックを切り離す
この記事で扱う問題
Eloquent のモデルは便利です。ただ、業務ロジックがモデルを直接受け取るようになると、次のことが起きます。
- ロジックの途中でリレーションを読むと、そこで SQL が走る(N+1 がロジックの中に潜む)
- どのカラムが読み込まれているかが呼び出し経路次第で変わり、
nullの意味が曖昧になる - ロジックのテストに DB とファクトリが必須になる
$model->save()をロジックのどこからでも呼べてしまう
つまり、永続化の都合がロジックに漏れ出している状態です。
判断
業務ロジックが使う入口を Repository Interface として定義し、Eloquent はその実装の中に閉じ込めます。
interface OrderRepository
{
public function findOrFail(OrderId $id): Order;
public function save(Order $order): void;
}
final class EloquentOrderRepository implements OrderRepository
{
public function findOrFail(OrderId $id): Order
{
$row = OrderModel::with('items')->findOrFail($id->value);
return OrderMapper::toDomain($row); // Eloquent モデル → ドメインのオブジェクト
}
public function save(Order $order): void
{
$model = OrderModel::findOrNew($order->id()->value); // 既存の行なら UPDATE、新規なら INSERT
OrderMapper::fill($model, $order); // ドメインのオブジェクト → Eloquent モデル
$model->push();
}
}
層をまたいでデータを渡すときは、Eloquent モデルではなく DTO(ただの値の入れ物)を使います。
final readonly class OrderSummary
{
public function __construct(
public string $id,
public string $status,
public int $totalAmount,
) {
}
}
なぜそう判断したか
ポイントは、ロジックが必要とするものを、ロジックの言葉で定義することです。
findOrFail(OrderId)は「注文を1件取り出す」というドメインの操作で、SQL やリレーションの読み込み方は実装側の責任になります- ロジックのテストでは、メモリ上の Fake Repository を使えます。DB なしで業務ルールだけを速く検証できます
- DTO は読み込まれている値が型で決まっているので、「この値は読み込まれていないのか、本当に null なのか」という曖昧さがなくなります
選ばなかったのは、「Eloquent モデルをドメインモデルとして使う」やり方です。Active Record は小さな規模では合理的ですが、業務ルールと永続化の変更理由が別々になってくると、1つのクラスに2つの理由で手が入り続けます。
どこから設計過剰になるか
この構成は、マッピングのコードが増えるのが明確なコストです。
- 単純な CRUD で業務ルールがほとんどない機能では、Repository を挟む利点がほぼありません
- 一覧・検索などの読み取りは、Repository を経由せず、専用のクエリクラスから DTO を直接返すほうが素直です(CQRS 的な分け方)
- 「将来 DB を変えるかもしれないから」だけを理由にするのは弱いです。本当の利点は、ロジックのテスト容易性と、変更理由の分離にあります
まとめ
- Eloquent モデルを業務ロジックに渡すと、永続化の都合がロジックに漏れる
- Repository Interface はドメインの言葉で定義し、Eloquent は実装に閉じ込める
- 層の間は DTO で渡し、読み取りは専用のクエリクラスで十分なことが多い