LLaravel Bootcamp

Day 6責務の分離とデータ整合性 — サービス層・トランザクション・排他制御

サービス層 — ビジネスロジックの置き場所

業務手順を専用クラスに集約し、コントローラ・モデルと責務を分担。DIで受け取る。

サービス層とは

とは、アプリの『業務手順(ビジネスロジック)』を担当する専用クラスの層です。前レッスンの注文処理のように、複数のモデルをまたいで『在庫を減らし、注文を作り、ポイントを付け、メールを送る』といった一連の手順を、ここに一つのメソッドとしてまとめます。

三者の責務分担

  • コントローラ: HTTPの入出力。リクエストを受け、サービスを呼び、結果をレスポンスにする。
  • サービス層: 業務手順の調整。複数モデルをまたぐ処理の順序や条件を決める。
  • モデル: 1つのテーブルに対応するデータと、そのデータ自身に閉じたルール。

悪い例と良い例

コントローラに全部書く

PHP
public function store(Request $request)
{
    // 在庫チェック・金額計算・在庫更新・注文作成・
    // ポイント付与・メール送信…すべてここに直書き
    $product = Product::find($request->product_id);
    if ($product->stock < $request->quantity) { /* ... */ }
    $amount = /* 割引計算 */;
    $product->decrement('stock', $request->quantity);
    $order = Order::create([/* ... */]);
    $request->user()->increment('points', /* ... */);
    Mail::to($request->user())->send(/* ... */);
    return redirect()->route('orders.show', $order);
}

サービスに委譲する

PHP
public function store(
    StoreOrderRequest $request,
    OrderService $orders
) {
    // 業務手順は OrderService に任せ、
    // コントローラは呼び出しと結果の受け渡しだけ
    $order = $orders->place(
        $request->user(),
        $request->validated()
    );

    return redirect()->route('orders.show', $order);
}

サービスクラスの中身

PHP
class OrderService
{
    public function place(User $user, array $data): Order
    {
        $product = Product::findOrFail($data['product_id']);

        if ($product->stock < $data['quantity']) {
            throw new OutOfStockException();
        }

        $amount = $this->calculateAmount($user, $product, $data['quantity']);

        $product->decrement('stock', $data['quantity']);

        $order = Order::create([
            'user_id'    => $user->id,
            'product_id' => $product->id,
            'quantity'   => $data['quantity'],
            'amount'     => $amount,
        ]);

        $user->increment('points', (int) ($amount * 0.01));
        Mail::to($user)->send(new OrderCompleted($order));

        return $order;
    }

    // 金額・割引の計算はプライベートメソッドに分けて見通しをよくする
    private function calculateAmount(User $user, Product $product, int $qty): int
    {
        $amount = $product->price * $qty;
        return $user->isPremium() ? (int) ($amount * 0.9) : $amount;
    }
}
app/Services/OrderService.php — 業務手順を1メソッドに集約

private ロジックを単体テストしたくなったら

上の calculateAmount は private なので、place() 越しにしかテストできません。割引計算そのものをピンポイントで単体テストしたい場合は、計算を public なメソッドや専用クラス(例: AmountCalculator)へ切り出すのが定石です。テストしやすさは『どこに何を置くか』という設計の指針にもなります(Day7のテストのレッスンで、切り出した計算を単体テストする例を見ます)。

なぜ業務手順をサービス層に集約するのか

複数モデルをまたぐ手順は、どのモデルにも属しません。コントローラに書けば再利用できず、無理に片方のモデルに押し込めば別のモデルへの依存が漏れます。中立なサービス層に置くことで、コントローラ・バッチ・APIのどこからでも同じ手順を呼べ、割引計算のような部分だけを単体テストできます。責務が『どこに何があるか』の形で整理されるのが利点です。なお、規模が大きくなり、データの取得・保存の詳細までサービスから切り離したくなった場合は、データアクセスを専用クラスに集約する (リポジトリパターン)を併用する選択肢もあります。

DI(依存性の注入)で受け取る

上の例では、コントローラのメソッド引数に「OrderService」を型で書くだけで、Laravelが自動的にインスタンスを作って渡してくれます。これが (依存性の注入)です。自分で「new OrderService()」せずに外から受け取ることで、テスト時には偽物(モック)に差し替えられ、依存関係が疎になります。

PHP
// アンチパターン: クラス内部で依存を固定してしまう
class OrderService
{
    public function place(...)
    {
        $mailer = new SmtpMailer(); // テストで差し替え不可
    }
}

// 良い例: コンストラクタで受け取る(DI)
class OrderService
{
    public function __construct(private Mailer $mailer) {}
    // $this->mailer はテスト時にモックへ差し替えられる
}
自前で new すると差し替えができない

サービスコンテナが解決する

型ヒントを見て必要な依存を組み立てて渡す仕組みは (サービスコンテナ)が担います。開発者は『何が必要か』を型で宣言するだけで、『どう作るか』はフレームワークに任せられます。

面接ではこう言う

「複数モデルをまたぐ業務手順はサービス層に集約します。コントローラはHTTP、モデルは自テーブルのデータ、サービスは業務手順、と責務を分けます。サービスはコンストラクタインジェクションで依存を受け取るので、テストではモックに差し替えられますし、バッチやAPIからも同じ手順を再利用できます」

読んでも腑に落ちない所は、AIに噛み砕いてもらいましょう。

理解度チェック

答えられれば、面接で説明できる状態に一歩近づきます。

Q1

サービス層に置くのに最も適した処理はどれですか。

Q2

DI(依存性の注入)でサービスを受け取る利点として最も適切なものは?

Q3

コントローラ・サービス層・モデルの責務分担を説明してください。

記述式です。まず自分の言葉で答えを考えてから、模範解答を開いて自己採点してください。