Eloquentモデルの責務 — 書いていい仕事と書くべきでない仕事
第8章では、Controllerに業務ロジックを詰め込んだFat Controllerと、その処理をそのままモデルに移しただけのFat Modelを見ました。 Fat Modelの問題は、Eloquentモデルという仕組み自体が悪いということではありません。 Eloquentが提供するリレーション・アクセサ・キャストといった機能を使わず、複数の関心事を1つのメソッドに詰め込んだことが問題でした。 この章では、Eloquentモデルが本来担うべき責務と、担うべきでない責務を分けて見ていきます。
Eloquentモデルの本来の責務
Eloquentモデルは、Active Recordパターンの実装です1。 1つのモデルインスタンスはデータベースの1行に対応し、その行が持つデータをどう表現し、どう解釈するかに責務を持ちます。 具体的には、次の4つがEloquentモデル自身の責務です。
リレーションの定義
注文(Order)が複数の注文明細(OrderItem)を持つという関係は、モデル自身が知っておくべき情報です。
class Order extends Model
{
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
}
$order->itemsと書けば、関連するOrderItemをまとめて取得できます。
これは「このOrderはどのOrderItemと結びついているか」という、Order自身のデータについての知識です。
アクセサとキャスト
保存された値をどう解釈して返すかも、モデル自身の責務です。
第1部で扱った例では、注文の合計金額をOrder::calculateTotal()というメソッドでOrderクラス自身が計算していました。
Eloquentモデルでも、同じ考え方をアクセサで表現できます。
class Order extends Model
{
protected function total(): Attribute
{
return Attribute::make(
get: fn (mixed $value, array $attributes) => $this->items
->sum(fn (OrderItem $item) => $item->price * $item->quantity),
);
}
}
$order->totalと書けば、明細から計算された合計金額を取得できます2。
この計算は「自分が持つ明細データをどう解釈するか」という、Orderのデータ表現の一部です。
保存された値の型をどう扱うかも同様です。
第2章では、決まった選択肢しか取らない値を表すためにOrderStatusというbacked enumを定義しました。
注文のステータスをこのenumとして扱いたい場合、casts()メソッドで指定します。
class Order extends Model
{
protected function casts(): array
{
return [
'status' => OrderStatus::class,
];
}
}
こう書くと、$order->statusは'pending'のような文字列ではなく、OrderStatusのインスタンスとして扱えます3。
保存されている値をどの型として読み書きするかは、Order自身のデータ表現に属する判断です。
ローカルスコープ
よく使う検索条件を再利用可能な形にすることも、モデルの責務に含まれます。
class Order extends Model
{
public function scopeForCustomer(Builder $query, string $email): void
{
$query->where('customer_email', $email);
}
}
Order::forCustomer($email)->get()のように、scopeという接頭辞を除いた名前で呼び出せます4。
これも「自分のテーブルをどう絞り込むか」という、Orderのデータについての知識です。
モデルに書くべきでない責務
一方で、第8章のFat Model例に戻ってみます。
class Order extends Model
{
public static function placeOrder(array $items, string $customerEmail): self
{
$total = 0;
foreach ($items as $item) {
$total += $item['price'] * $item['quantity'];
}
foreach ($items as $item) {
$stock = DB::table('stocks')->where('name', $item['name'])->value('quantity');
if ($stock < $item['quantity']) {
throw new RuntimeException('在庫が不足しています');
}
}
$order = self::create([
'customer_email' => $customerEmail,
'total' => $total,
]);
Mail::raw("ご注文ありがとうございます。合計{$total}円です。", function ($message) use ($customerEmail) {
$message->to($customerEmail)->subject('注文確認');
});
return $order;
}
}
placeOrder()が行っていることを分解すると、次のようになります。
- 合計金額を計算する
- 在庫テーブル(
stocks)を確認する - 注文レコードを作成する
- メールを送信する
このうち、合計金額の計算は、前半で見たアクセサに切り出せる処理です。 一方、在庫確認とメール送信は、Orderというデータの表現とは関係がありません。 在庫確認は在庫という別の関心事についての判断であり、メール送信は通知という別の関心事の実行です。 どちらも、Orderの行が持つデータをどう解釈するかとは無関係な処理です。
判断基準
Eloquentモデルに書いていい処理かどうかは、次の問いで判断できます。
その処理は、このモデル自身が持つデータの表現・解釈だけで完結するか。
リレーション・アクセサ・キャスト・ローカルスコープは、いずれも「自分の行のデータをどう見せるか」という問いに答えるものです。 一方、在庫確認は在庫テーブルという他のデータへの問い合わせであり、メール送信は外部のメール配信の仕組みへの依頼です。 どちらも、Order自身のデータの解釈を超えて、複数の関心事を調整する処理です。
この「複数の関心事を調整する処理」の置き場所が、第10章で扱うサービスクラスです。
まとめ
- Eloquentモデルの責務は、自分が表す行のデータをどう表現し解釈するかに閉じている
- リレーション・アクセサ/ミューテタ・キャスト・ローカルスコープは、いずれもモデル自身のデータについての知識であり、モデルに書いてよい
- 在庫確認やメール送信のように、複数の関心事にまたがる処理は、モデルの責務を超える
- 判断基準は「その処理がモデル自身のデータの表現・解釈だけで完結するか」
次に読む
次章では、モデルの責務を超える処理をどこに置くかを扱います。 Controllerが肥大化したときに、Serviceクラスへ処理を切り出す判断基準を見ていきます。
練習問題
次の処理は、Eloquentモデルに書いてよい責務でしょうか。理由とともに答えてください
- 注文の
itemsリレーションから、注文点数の合計を計算して返すアクセサ - 注文が確定済みかどうかを、他の注文履歴テーブルと突き合わせて判定する処理
- 注文ステータスを
enumにキャストする設定
解答例
- 書いてよい: 自分が持つリレーションのデータを解釈しているだけなので、モデル自身の責務です
- 書くべきでない: 他のテーブルとの突き合わせは、Order自身のデータの解釈を超えた判断です
- 書いてよい: 保存された値の型をどう扱うかは、モデル自身の責務です