メインコンテンツまでスキップ

サービスクラスへの責務分割: Controller肥大化を防ぐ切り出し

第 9 章では、Eloquent モデルの責務を「自分が表すデータの表現・解釈」に絞りました。 在庫確認やメール送信のような、複数の関心事を調整する処理はモデルの外に出すという判断基準でした。 では、モデルの外に出した処理は、どこに置けばよいのでしょうか。 この章では、Controller に残った調整処理をサービスクラスへ切り出す判断基準を見ていきます。

Controllerに残った調整処理​

第 8 章の Fat Controller 例をもう一度見てみます。

❌ Bad: Fat Controller(第8章の再掲)
class OrderController extends Controller
{
public function store(Request $request)
{
$validated = $request->validate([
'items' => 'required|array',
'customer_email' => 'required|email',
]);

$total = 0;
foreach ($validated['items'] as $item) {
$total += $item['price'] * $item['quantity'];
}

foreach ($validated['items'] as $item) {
$stock = DB::table('stocks')->where('name', $item['name'])->value('quantity');
if ($stock < $item['quantity']) {
return response()->json(['error' => '在庫が不足しています'], 422);
}
}

$order = Order::create([
'customer_email' => $validated['customer_email'],
'total' => $total,
]);

Mail::raw("ご注文ありがとうございます。合計{$total}円です。", function ($message) use ($validated) {
$message->to($validated['customer_email'])->subject('注文確認');
});

return response()->json($order, 201);
}
}

第 9 章の判断基準に沿って整理すると、store()の中身は次のように分かれます。

  • リクエストのバリデーション: Controller(または Form Request)の責務
  • 合計金額の計算: Order モデルのアクセサとして表現できる(第 9 章)
  • 在庫確認・注文作成・メール送信: 複数の関心事を調整する処理

在庫確認・注文作成・メール送信の 3 つは、Order モデルのデータ表現には収まりません。 在庫確認は在庫という別の関心事、メール送信は通知という別の関心事であり、注文作成はその 2 つと合わせて一連の手順として実行される必要があります。 この「一連の手順の調整」を置く場所が、サービスクラスです。

サービスクラスへの切り出し​

Laravel にはサービスクラスという公式の仕組みはありません1。 複数の関心事を調整する処理をひとまとまりのクラスに切り出す、コミュニティで広く使われている命名慣習です。

✅ Good: サービスクラスへの切り出し
class OrderPlacementService
{
public function __construct(
private readonly StockChecker $stockChecker,
) {
}

public function placeOrder(array $items, string $customerEmail): Order
{
if (! $this->stockChecker->hasEnoughStock($items)) {
throw new RuntimeException('在庫が不足しています');
}

$order = Order::create([
'customer_email' => $customerEmail,
]);

foreach ($items as $item) {
$order->items()->create($item);
}

Mail::raw("ご注文ありがとうございます。合計{$order->total}円です。", function ($message) use ($customerEmail) {
$message->to($customerEmail)->subject('注文確認');
});

return $order;
}
}

class StockChecker
{
public function hasEnoughStock(array $items): bool
{
foreach ($items as $item) {
$stock = DB::table('stocks')->where('name', $item['name'])->value('quantity');
if ($stock < $item['quantity']) {
return false;
}
}
return true;
}
}

Controller は、バリデーション結果をOrderPlacementServiceに渡すだけになります。

✅ Good: 薄くなったController
class OrderController extends Controller
{
public function __construct(
private readonly OrderPlacementService $orderService,
) {
}

public function store(Request $request)
{
$validated = $request->validate([
'items' => 'required|array',
'customer_email' => 'required|email',
]);

try {
$order = $this->orderService->placeOrder($validated['items'], $validated['customer_email']);
} catch (RuntimeException $e) {
return response()->json(['error' => $e->getMessage()], 422);
}

return response()->json($order, 201);
}
}

OrderPlacementServiceが投げた例外を Controller が受け取り、HTTP レスポンスへ変換しています。 例外を使ったエラーの伝え方そのものは第 12 章で扱うため、ここでは役割分担だけを見てください。

OrderPlacementServiceは在庫確認・注文作成・メール送信という一連の手順を調整し、StockCheckerは在庫確認という 1 つの関心事に専念しています。 これは第 4 章で見た、Order クラスからStockCheckerやOrderMailerを切り出した分割と同じ考え方です2。 ここでのStockCheckerは、第 4 章のように Order クラスのインスタンスを受け取るのではなく、Laravel のバリデーション結果である配列を直接受け取る形にしています。 コンストラクタでの受け取り方の詳細は第 11 章で扱います。

凝集度を保つ ── ファイルを分けただけにしないために​

サービスクラスへの切り出しは、ファイルを分けることそのものが目的ではありません。 次の例を見てください。

❌ Bad: 無関係な処理まで抱え込んだサービスクラス
class OrderPlacementService
{
public function placeOrder(array $items, string $customerEmail): Order
{
// 注文作成の処理
}

public function generateMonthlySalesReport(): array
{
// 月次売上レポートの集計処理
}

public function suspendCustomer(string $email): void
{
// 問題のある顧客のアカウント停止処理
}
}

placeOrder()・generateMonthlySalesReport()・suspendCustomer()は、変更される理由がそれぞれ異なります。 注文の流れが変わる理由、売上集計の仕様が変わる理由、アカウント停止のポリシーが変わる理由は、互いに無関係です3。 OrderControllerから処理を追い出しただけで、複数の関心事を 1 つのクラスに詰め込む構造そのものは変わっていません。 これは、第 1 章で見た god class と同じ問題です。

サービスクラスも、第 4 章・第 7 章で見た単一責任の原則や凝集度の考え方から自由ではありません。 OrderPlacementServiceは「注文を確定するまでの一連の手順の調整」という 1 つの関心事に絞り、売上集計やアカウント管理は別のサービスクラスに分けます。

サービスクラスとユースケース層の違い​

ここまでのOrderPlacementServiceは、在庫確認・注文作成・メール送信という手順を 1 つのメソッドにまとめ、大きくなった Controller を分割するという、クラス設計の視点にとどまっています。

一方で、Laravel × DDD × クリーンアーキテクチャ実践ガイドでは、同じような役割を持つクラスを「ユースケース」と呼び、トランザクション境界の管理やドメインイベントの発行まで含めて設計します。 本ガイドはクラス設計の範囲にとどまるため、これらのアーキテクチャ上の設計には立ち入りません。 本格的なアプリケーション層の設計を学びたい場合は、同ガイドの該当章を参照してください。

まとめ​

  • Controller に残った、複数の関心事を調整する処理はモデルの責務を超える
  • 調整処理はサービスクラスへ切り出す。Laravel の公式機能ではなく、コミュニティの命名慣習として広く使われている
  • サービスクラスへの切り出しは、ファイルを分けることが目的ではない。切り出した後も単一責任・凝集度を保つ
  • トランザクション境界の管理やドメインイベントの発行といったアプリケーション層の設計は、本ガイドの範囲外

次に読む​

次章では、サービスクラスをControllerへどう渡すかを扱います。 依存性注入と Laravel のコンテナの基本的な使い方を見ていきます。

練習問題​

次のサービスクラスの設計は適切でしょうか。理由とともに答えてください
class UserService
{
public function register(string $email, string $password): User { /* ... */ }
public function login(string $email, string $password): bool { /* ... */ }
public function exportUsersToCsv(): string { /* ... */ }
}

解答例

register()・login()はどちらも「ユーザー認証」という関心事に関わる処理で、同じ理由で変更されやすい処理です。 一方、exportUsersToCsv()はレポート出力という別の関心事であり、CSV の形式が変わっても認証の仕様には影響しません。 exportUsersToCsv()は別のサービスクラス(例: UserExportService)に分けたほうが、凝集度を保てます。


Footnotes​

  1. 出典: Laravel Service Classes Explained(DEV Community)。サービスクラスは Laravel のフレームワーク自体に組み込まれた仕組みではなく、公式ドキュメントにも定義されていない、コミュニティで広く使われる設計パターンであると説明されています。 ↩

  2. 第 4 章で見たStockChecker・OrderMailerへの分割を参照してください。 ↩

  3. 出典: 第 4 章で扱った単一責任の原則(出典: Single responsibility principle、Robert C. Martin, 2003 年)。 ↩