【Laravel】MVCを実践する:太らせないControllerの書き方

laravelアイキャッチ Laravel

こんにちは、かつコーチです。

前回はパフォーマンスチューニングの基本を解説しました。

今回からは設計・アーキテクチャ編に入り、最初のテーマとして「Fat Controller(太ったコントローラ)」問題を扱います。

Laravelは書きやすさゆえに、気づくとControllerにあらゆる処理を詰め込んでしまいがちです。

私自身、実務で「1つのメソッドが150行を超えるController」を見てきた経験を踏まえて、どう整理していけばいいかを具体的に解説します。

なぜControllerは太りやすいのか

Controllerの本来の役割

MVCにおけるControllerの役割は、本来とてもシンプルです。

  • リクエストを受け取る
  • 必要な処理(バリデーション・ビジネスロジック・データ取得)を呼び出す
  • 結果をレスポンス(ビューやJSON)として返す

つまりControllerは「交通整理役」であって、「ビジネスロジックそのものを実装する場所」ではありません。

ところが、Laravelは RequestModel に気軽にアクセスできてしまうため、動くコードを書き進めるうちに、本来Controllerが担うべきでない処理まで詰め込みやすい構造になっています。

Fat Controllerが引き起こす問題

Controllerが太ってしまうと、次のような問題が発生します。

  • 同じロジックを別の画面やAPIでも使いたいとき、コピペするしかなくなる
  • テストを書こうとすると、HTTPリクエストを経由しないとロジックを検証できない
  • 1つのメソッドが長くなり、レビューで見落としが発生しやすくなる
  • 仕様変更のたびに、巨大なメソッドのどこを直せばいいか探す時間が増える

私が過去に引き継いだプロジェクトでは、注文確定処理を行う1つのControllerメソッドが200行近くあり、在庫チェック・ポイント計算・メール送信・決済処理がすべて同じメソッドの中に混在していました。

新しいクーポン機能を追加する際、既存のどの処理に影響が出るのかを把握するだけで丸1日かかった、という苦い経験があります。

Fat Controllerの具体例とリファクタリング

Before:あらゆる処理が詰め込まれたController

まずは、よくあるFat Controllerの例を見てみましょう。

❌ Before:バリデーション以外のあらゆる処理がControllerに直書きされている

// app/Http/Controllers/OrderController.php
namespace App\Http\Controllers;

use App\Models\Order;
use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;

class OrderController extends Controller
{
    public function store(Request $request)
    {
        $validated = $request->validate([
            'product_id' => 'required|exists:products,id',
            'quantity' => 'required|integer|min:1',
        ]);

        $product = Product::findOrFail($validated['product_id']);

        // 在庫チェック
        if ($product->stock < $validated['quantity']) {
            return back()->withErrors(['quantity' => '在庫が不足しています']);
        }

        // 金額計算
        $subtotal = $product->price * $validated['quantity'];
        $tax = (int) round($subtotal * 0.1);
        $total = $subtotal + $tax;

        // 会員ランクによる割引
        $user = $request->user();
        if ($user->rank === 'gold') {
            $total = (int) round($total * 0.9);
        }

        DB::beginTransaction();
        try {
            $order = Order::create([
                'user_id' => $user->id,
                'product_id' => $product->id,
                'quantity' => $validated['quantity'],
                'total_price' => $total,
                'status' => 'confirmed',
            ]);

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

            // ポイント付与
            $user->increment('points', (int) floor($total * 0.01));

            DB::commit();
        } catch (\Throwable $e) {
            DB::rollBack();
            report($e);
            return back()->withErrors(['order' => '注文処理に失敗しました']);
        }

        Mail::to($user->email)->send(new \App\Mail\OrderConfirmedMail($order));

        return redirect()->route('orders.show', $order)->with('status', '注文が確定しました');
    }
}

バリデーション、在庫チェック、金額計算、割引ロジック、トランザクション処理、ポイント付与、メール送信まで、すべてが1つのメソッドに同居しています。

このコードには、Controllerが太ることによる問題点がすべて詰まっています。

責務ごとにクラスを分離する

このメソッドを、責務ごとに分割していきます。

まず、バリデーションは FormRequest に切り出します。

// app/Http/Requests/StoreOrderRequest.php
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreOrderRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'product_id' => 'required|exists:products,id',
            'quantity' => 'required|integer|min:1',
        ];
    }
}

金額計算や割引ロジック、在庫チェックといった「注文に関するビジネスロジック」は、専用のサービスクラスに切り出します。

// app/Services/OrderService.php
namespace App\Services;

use App\Exceptions\InsufficientStockException;
use App\Mail\OrderConfirmedMail;
use App\Models\Order;
use App\Models\Product;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;

class OrderService
{
    public function placeOrder(User $user, Product $product, int $quantity): Order
    {
        if ($product->stock < $quantity) {
            throw new InsufficientStockException('在庫が不足しています');
        }

        $total = $this->calculateTotal($product, $quantity, $user);

        $order = DB::transaction(function () use ($user, $product, $quantity, $total) {
            $order = Order::create([
                'user_id' => $user->id,
                'product_id' => $product->id,
                'quantity' => $quantity,
                'total_price' => $total,
                'status' => 'confirmed',
            ]);

            $product->decrement('stock', $quantity);
            $user->increment('points', (int) floor($total * 0.01));

            return $order;
        });

        Mail::to($user->email)->send(new OrderConfirmedMail($order));

        return $order;
    }

    private function calculateTotal(Product $product, int $quantity, User $user): int
    {
        $subtotal = $product->price * $quantity;
        $tax = (int) round($subtotal * 0.1);
        $total = $subtotal + $tax;

        if ($user->rank === 'gold') {
            $total = (int) round($total * 0.9);
        }

        return $total;
    }
}

DB::transaction() にクロージャを渡す書き方にすることで、beginTransaction()commit()rollBack() を手動で書かなくても、例外発生時に自動でロールバックされるようになります。

✅ After:Controllerは受け取り・呼び出し・返却に専念する

// app/Http/Controllers/OrderController.php
namespace App\Http\Controllers;

use App\Exceptions\InsufficientStockException;
use App\Http\Requests\StoreOrderRequest;
use App\Models\Product;
use App\Services\OrderService;

class OrderController extends Controller
{
    public function __construct(private OrderService $orderService)
    {
    }

    public function store(StoreOrderRequest $request)
    {
        $validated = $request->validated();
        $product = Product::findOrFail($validated['product_id']);

        try {
            $order = $this->orderService->placeOrder(
                $request->user(),
                $product,
                $validated['quantity']
            );
        } catch (InsufficientStockException $e) {
            return back()->withErrors(['quantity' => $e->getMessage()]);
        }

        return redirect()->route('orders.show', $order)->with('status', '注文が確定しました');
    }
}

200行近くあったメソッドが、Controllerの store() はわずか十数行になりました。

バリデーションルールの変更は StoreOrderRequest を、金額計算のロジック変更は OrderService を見ればよく、「どこを直せばいいか」が一目で分かる状態になっています。

サービスクラスに切り出すメリット

テストが書きやすくなる

Fat Controllerのままだと、注文処理をテストするたびにHTTPリクエストを経由する必要があり、テストの実行速度も可読性も悪化しがちです。

OrderService に切り出したことで、HTTPを経由せずにビジネスロジックだけを単体テストできるようになります。

// tests/Unit/OrderServiceTest.php
namespace Tests\Unit;

use App\Exceptions\InsufficientStockException;
use App\Models\Product;
use App\Models\User;
use App\Services\OrderService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class OrderServiceTest extends TestCase
{
    use RefreshDatabase;

    public function test_在庫が不足している場合は例外が発生する(): void
    {
        $user = User::factory()->create();
        $product = Product::factory()->create(['stock' => 1]);

        $this->expectException(InsufficientStockException::class);

        app(OrderService::class)->placeOrder($user, $product, 5);
    }

    public function test_ゴールド会員は割引価格で注文できる(): void
    {
        $user = User::factory()->create(['rank' => 'gold']);
        $product = Product::factory()->create(['price' => 1000, 'stock' => 10]);

        $order = app(OrderService::class)->placeOrder($user, $product, 1);

        // 1000円 + 消費税10% = 1100円 → ゴールド会員10%引きで990円
        $this->assertSame(990, $order->total_price);
    }
}

金額計算のロジックはビジネス上とても重要な一方、条件分岐が多く間違いやすい部分でもあります。

サービスクラスとして独立させたことで、こうした計算ロジックのパターンを網羅的にテストしやすくなる点が、Fat Controllerを解消する最大のメリットだと感じています。

どこまで分離するかの判断軸

とはいえ、すべての処理を無条件にサービスクラスへ切り出すべきというわけではありません。

私は次のような判断軸で「切り出すかどうか」を決めています。

  • 単純な1〜2行のクエリだけなら、Controllerに残してもよい
  • 条件分岐やトランザクションが絡む処理は、サービスクラスに切り出す
  • 複数のController・複数の画面から同じロジックを呼びたい場合は、迷わず切り出す
  • ロジックにテストを書きたいと感じたら、それは切り出すべきサインと捉える

「とりあえず全部サービスクラスに切り出す」というルールにしてしまうと、逆に薄すぎるクラスが大量に増えて見通しが悪くなることもあります。

「このロジックは今後、別の画面でも使う可能性があるか」「テストで守りたい重要な計算か」を基準に判断するのが、実務ではバランスが良いと感じています。

まとめ

この記事のポイント

  • Controllerの本来の役割は「受け取り・呼び出し・返却」の交通整理であり、ビジネスロジックの実装場所ではない
  • バリデーションは FormRequest、ビジネスロジックはサービスクラスに切り出すと責務が明確になる
  • DB::transaction() にクロージャを渡すと、ロールバック処理を手動で書かずに済む
  • ロジックを切り出すとHTTPを経由しない単体テストが書けるようになり、保守性が上がる
  • すべてを機械的に切り出すのではなく、条件分岐の複雑さや再利用性を判断軸にする

次に読むべき記事

Controllerを太らせない書き方の次は、Service・Repositoryパターンをより体系的に導入するメリットと実装例を解説していきます。

→ 次の記事:Service・Repositoryパターンを導入するメリットと実装例

タイトルとURLをコピーしました