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

サービスクラスへの責務分割 — 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クラスからStockCheckerOrderMailerを切り出した分割と同じ考え方です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()は、変更される理由がそれぞれ異なります。 注文の流れが変わる理由、売上集計の仕様が変わる理由、アカウント停止のポリシーが変わる理由は、互いに無関係です3OrderControllerから処理を追い出しただけで、複数の関心事を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章で見たStockCheckerOrderMailerへの分割を参照してください。

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