🎉

LaravelでControllerをスリムにする!UseCase + DTOで始めるDDD設計入門

に公開2

この記事では、UseCaseとDTO(データ転送オブジェクト)を使って、Controllerをスッキリさせる方法を書いていきます。


🔥 Before:バリデーションから保存まで全部コントローラ

public function store(Request $request)
{
    $data = $request->validate([
        'quote' => 'required|string|max:255',
        'response' => 'required|string|max:10000',
    ]);

    $reflection = auth()->user()->reflections()->create($data);

    return response()->json($reflection, 201);
}
  • バリデーション
  • 永続化
  • ログインユーザーの扱い

全部が Controller に書かれていて、責務が重くなってる状態です。


✅ After:UseCase / DTO に責務を分離!

📄 ReflectionData(DTO)

class ReflectionData
{
    public function __construct(
        public string $quote,
        public string $response
    ) {}
}

📄 StoreReflectionRequest

public function toDto(): ReflectionData
{
    return new ReflectionData(
        $this->input('quote'),
        $this->input('response'),
    );
}

📄 CreateReflectionUseCase

public function handle(User $user, ReflectionData $data): Reflection
{
    return $user->reflections()->create([
        'quote' => $data->quote,
        'response' => $data->response,
    ]);
}

📄 ReflectionController

public function store(StoreReflectionRequest $request, CreateReflectionUseCase $useCase)
{
    $reflection = $useCase->handle(auth()->user(), $request->toDto());

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

🧠 メリット

Before After
バリデーションと保存が同居 役割を分離(RequestとUseCase)
$request->validate() が Controllerに直接ある toDto() で型安全なデータを渡す
Controllerが肥大化しやすい Controllerは「APIの入り口」としてスリムに

✨ 応用:更新・削除もUseCaseへ

更新・削除も同様に UpdateReflectionUseCaseDeleteReflectionUseCase に分離できます。

public function update(UpdateReflectionRequest $request, Reflection $reflection, UpdateReflectionUseCase $useCase)
{
    $updated = $useCase->handle(auth()->user(), $reflection, $request->toDto());
    return response()->json($updated);
}

📦 ディレクトリ構成

app/
├── UseCases/
│   └── Reflection/
├── Dto/
│   └── ReflectionData.php
├── Http/Requests/
│   └── StoreReflectionRequest.php
│   └── UpdateReflectionRequest.php

✅ まとめ

  • LaravelでもUseCase/DTOを導入すれば、Controllerをスリム化&テストしやすくできる
  • テストを書きやすい
  • 将来的にPolicy、Resource、Repositoryなどへも拡張しやすい

✍️ おまけ:次のステップ

  • Policy導入で認可もUseCaseから分離
  • Presenter / Resource 導入でレスポンス整形
  • Repositoryパターン導入でDB層の切り分け

Discussion

手洗う手洗う

UseCaseクラスの使い方が自分と全く同じでビックリしました。
毎回UseCaseクラスを手動で作るのが面倒なので、usecaseコマンドを登録し、

php artisan make:usecase Test

を叩くと、以下のインターフェースを実装した 命名+Action クラスを生成するようにしています。

app/UseCases/TestAction.php
<?php

namespace App\UseCases;

use App\UseCases\UseCaseHandler;

/**
 * TODO: どういうユースケースなのか記載
 */
final class TestAction implements UseCaseHandler
{
    public function __construct()
    {
    
    }
    
    public function handle()
    {
        // TODO: ロジックを書くます
    }
}
App\UseCases\UseCaseHandler
interface UseCaseHandler
{
    public function handle();
}

登録しているコマンド

app/Console/Commands/MakeUseCaseCommand.php
<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Filesystem\Filesystem;

class MakeUseCaseCommand extends Command
{
    // コマンドのシグネチャと説明
    protected $signature = 'make:usecase {name}';
    protected $description = 'Create a new use case class with a handle method';

    protected $files;

    public function __construct(Filesystem $files)
    {
        parent::__construct();
        $this->files = $files;
    }

    public function handle()
    {
        // UseCaseInterface の存在チェックと生成
        $this->createUseCaseInterfaceIfNotExists();

        // 引数からディレクトリとクラス名を取得(例: Customer/DeleteCustomer)
        $name = $this->argument('name');
        $parts = explode('/', $name);
        $classBase = array_pop($parts);
        $className = $classBase.'Action';

        // app/UseCases 以下のパスを生成
        $directoryPath = app_path('UseCases/'.implode('/', $parts));
        $fullPath = $directoryPath.'/'.$className.'.php';

        // 同一ファイルが存在する場合はエラー
        if ($this->files->exists($fullPath)) {
            $this->error("UseCase already exists at {$fullPath}!");

            return;
        }

        // ディレクトリが存在しなければ作成
        if (!$this->files->isDirectory($directoryPath)) {
            $this->files->makeDirectory($directoryPath, 0755, true);
        }

        // 名前空間の組み立て(例: App\UseCases\Customer)
        $namespace = 'App\\UseCases';
        if (!empty($parts)) {
            $namespace .= '\\'.implode('\\', $parts);
        }

        // スタブ(テンプレート)の取得と置換
        $stub = $this->getStub();
        $stub = str_replace('{{ namespace }}', $namespace, $stub);
        $stub = str_replace('{{ class }}', $className, $stub);

        // ファイルの生成
        $this->files->put($fullPath, $stub);

        $this->info("UseCase created successfully at {$fullPath}");
    }

    /**
     * UseCaseInterface が存在しなければ生成する
     */
    protected function createUseCaseInterfaceIfNotExists()
    {
        $interfacePath = app_path('UseCases/UseCaseHandler.php');

        if (!$this->files->exists($interfacePath)) {
            // ディレクトリがなければ作成
            $interfaceDir = dirname($interfacePath);
            if (!$this->files->isDirectory($interfaceDir)) {
                $this->files->makeDirectory($interfaceDir, 0755, true);
            }

            $interfaceStub = <<<'STUB'
<?php

namespace App\UseCases;

interface UseCaseHandler
{
    public function handle();
}
STUB;
            $this->files->put($interfacePath, $interfaceStub);
            $this->info("UseCaseInterface created successfully at {$interfacePath}");
        }
    }

    /**
     * UseCase のスタブテンプレート
     *
     * @return string
     */
    protected function getStub()
    {
        return <<<'STUB'
<?php

namespace {{ namespace }};

use App\UseCases\UseCaseHandler;

/**
 * TODO: どういうユースケースなのか記載
 */
final class {{ class }} implements UseCaseHandler
{
    public function __construct()
    {
    
    }
    
    public function handle()
    {
        // TODO: ロジックを書くます
    }
}
STUB;
    }
}

いだパンブログいだパンブログ

ありがとうございます!
これはすごい工夫ですね!
ユースケースごとに作成するとファイル数も増えてきますので、こうしてテンプレート化すれば確認するところも省略できます。
勉強になります!ありがとうございます!