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

実践 — クラス設計のリファクタリング

第1章ではgod classを、第8章ではFat Controller・Fat Modelを見ました。 そこから第12章まで、責務を分ける判断基準を1つずつ積み上げてきました。 この章では、第8章のFat Controllerを最初から最後まで通しでリファクタリングし、ここまでの判断基準がどうつながるかを確認します。 新しい判断基準は登場しません。ここまでの章で見た基準を、順番に適用していくだけです。

Beforeを診断する

第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);
}
}

第7章の物差しで診断すると、store()は凝集度が低い状態です。 バリデーション・合計計算・在庫確認・注文作成・メール送信という、変更される理由が異なる処理が1つのメソッドに同居しています。 リクエストの形式が変わる理由、在庫の扱いが変わる理由、メール文面が変わる理由は、互いに無関係です。

Step 1: モデルの責務を整理する

第9章の判断基準は、「その処理がモデル自身のデータの表現・解釈だけで完結するか」でした。 この基準に沿って、合計金額の計算をOrderモデルのアクセサへ移します。

✅ Good: 第9章で見たOrderモデル
class Order extends Model
{
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}

protected function total(): Attribute
{
return Attribute::make(
get: fn (mixed $value, array $attributes) => $this->items
->sum(fn (OrderItem $item) => $item->price * $item->quantity),
);
}
}

在庫確認とメール送信は、Order自身のデータ表現には収まらないため、モデルには移しません。

Step 2: 調整処理をサービスクラスへ切り出す

在庫確認・注文作成・メール送信という一連の手順は、第10章の判断基準どおりサービスクラスへ切り出します。

✅ Good: 第10章で見たOrderPlacementService
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;
}
}

OrderPlacementServiceは「注文を確定するまでの手順の調整」、StockCheckerは「在庫確認」という、それぞれ1つの関心事に専念しています。 第7章の物差しで言えば、クラスを分けたことで、それぞれの凝集度が上がっています。

Step 3: 依存は型で受け取る

OrderPlacementServiceStockCheckerという具象クラスに依存する部分は、第11章で見たとおり、コンストラクタの型宣言だけでLaravelのサービスコンテナが自動的に解決します。 OrderControllerOrderPlacementServiceを受け取る部分も同様です。

✅ Good: 型宣言だけで解決される
class OrderController extends Controller
{
public function __construct(
private readonly OrderPlacementService $orderPlacementService,
) {
}
}

OrderPlacementServiceStockCheckerも、newで直接組み立てられることなく、コンストラクタの型宣言だけで受け渡されています。 これは第11章で見た、具象クラス同士の依存をLaravelのサービスコンテナが自動解決する仕組みによるものです。 StockCheckerは具象クラスのままなので、実装を差し替えられるようにしたい場合は、第6章・第11章で見たようにインターフェースを介した依存に切り替える必要があります。

Step 4: バリデーションと例外を整理する

最後に、第12章の判断基準に沿って、リクエストの形式チェックをForm Requestへ、在庫不足の合図をカスタム例外へ切り出します。

✅ Good: 第12章で見たStoreOrderRequestとInsufficientStockException
class StoreOrderRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

public function rules(): array
{
return [
'items' => ['required', 'array'],
'items.*.name' => ['required', 'string'],
'items.*.price' => ['required', 'integer', 'min:0'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
'customer_email' => ['required', 'email'],
];
}
}

class InsufficientStockException extends RuntimeException
{
}

例外からHTTPレスポンスへの変換は、bootstrap/app.phpwithExceptions()に登録済みなので、Controllerはtry-catchを書く必要がありません。

Afterの全体像

ここまでの4ステップで、それぞれの処理がどこに移ったかをまとめます。

処理Beforeの置き場所Afterの置き場所適用した判断基準
リクエストの形式チェックController内に直書きStoreOrderRequest第12章
合計金額の計算Controller内のループOrderモデルのアクセサ第9章
在庫確認Controller内のループStockChecker第10章
注文作成・メール送信の調整Controller内に直書きOrderPlacementService第10章
在庫不足の伝え方HTTPレスポンスを直接返すInsufficientStockException第12章

最後に残ったControllerは、次のようになります。

✅ Good: Afterの薄いController
class OrderController extends Controller
{
public function __construct(
private readonly OrderPlacementService $orderPlacementService,
) {
}

public function store(StoreOrderRequest $request)
{
$order = $this->orderPlacementService->placeOrder(
$request->validated()['items'],
$request->validated()['customer_email'],
);

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

Beforeのstore()は27行かけて5つの関心事を扱っていましたが、Afterのstore()は「Form Requestで検証されたデータをサービスクラスへ渡し、結果をレスポンスに変換する」という1つの関心事だけを扱っています。 5つの関心事は消えたわけではなく、それぞれに適した場所へ移動しています。

まとめ

  • Fat Controllerは、凝集度の低さ(変更理由が異なる処理の同居)として診断できる
  • モデルの責務(第9章)・サービスクラスへの切り出し(第10章)・依存性注入(第11章)・例外とバリデーション(第12章)の4つの判断基準を順に適用すると、Controllerは1つの関心事だけを扱う薄いクラスになる
  • リファクタリングは、処理を消すのではなく、それぞれの処理に適した置き場所を選び直す作業です

次に読む

次章は本連載のまとめです。 実務でクラス設計を自分で判断するためのチェックリストを見ていきます。

練習問題

次のController(サービスクラスへの切り出し前)を見て、5つの関心事を書き出し、それぞれをどのクラスへ移すべきか、対応する章とともに答えてください
class ReviewController extends Controller
{
public function store(Request $request)
{
$validated = $request->validate([
'product_id' => 'required|integer',
'rating' => 'required|integer|min:1|max:5',
'comment' => 'required|string|max:1000',
]);

$product = Product::find($validated['product_id']);
if ($product === null) {
return response()->json(['error' => '商品が見つかりません'], 404);
}

$review = Review::create($validated);

$averageRating = Review::where('product_id', $validated['product_id'])->avg('rating');
$product->update(['average_rating' => $averageRating]);

Mail::raw('新しいレビューが投稿されました', function ($message) {
$message->to('shop-owner@example.com')->subject('新着レビュー');
});

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

解答例

  • リクエストの形式チェック(requiredminmax) → Form Request(第12章)
  • 商品の存在確認 → カスタム例外(例: ProductNotFoundException、第12章)。あるいはProduct::findOrFail()でLaravelの標準例外に任せる選択肢もある
  • 平均評価の再計算 → 再計算した平均をProduct側に書き戻す($product->update([...]))副作用を伴うため、単一モデルのデータ解釈にとどまらない。調整処理としてサービスクラスへ(第10章)
  • レビュー作成・平均評価の更新・メール送信の一連の手順 → サービスクラス(例: ReviewService、第10章)
  • サービスクラスの受け渡し → Controllerのコンストラクタで型宣言するだけでよい(第11章)