Laravel / PHP ·

FormRequestとUseCaseで入力検証をどう分担するか

  • Laravel
  • バリデーション
  • 責務分離

この記事で扱う問題

Laravel では、FormRequest の rules() に検証を書けば、エラーメッセージ付きの 422 が返ります。便利なので、検証は何でも FormRequest に書きたくなります。

public function rules(): array
{
    return [
        'sku' => ['required', 'string', Rule::exists('products', 'sku')->where('on_sale', true)],
        'quantity' => ['required', 'integer', 'min:1'],
        // 「注文の合計は100万円まで」も、after() で注文を読んで確かめる……
    ];
}

これを続けると、例えば次のことが起きます。

  • 同じ UseCase をキューのジョブや CSV の取り込みからも呼ぶようになり、そちらの経路では「販売中の商品か」が確かめられない
  • FormRequest で注文の合計を確かめてから UseCase が保存するまでの間に、別のリクエストが明細を足し、上限を超える
  • 業務ルールが FormRequest と UseCase の両方に書かれ、片方だけ直される

逆に、すべてを UseCase やドメインのオブジェクトに任せると、最初に見つかった違反で例外が出るだけになり、「SKU が空で、数量も数字ではない」のように、複数の項目の誤りをまとめて利用者に返せません。

どちらに寄せても困るので、私は検証を種類で分けて、置き場所を決めています。

判断

検証を3つに分ける

種類 例 置き場所 違反したとき
入力の形式 必須か、整数か、文字数、SKU の書式 FormRequest(入口ごと) 422。項目ごとのメッセージをまとめて返す
値の不変条件 数量は1以上、金額は0以上 Value Object のコンストラクタ 入口で弾けていなければプログラムの誤り。500
状態や他のデータに依存するルール 販売中の商品か、確定前の注文か、合計が上限以下か UseCase とドメインのオブジェクト ドメインの例外を、入口でレスポンスに変換する

分ける基準は、そのルールを確かめるのに、入力の値のほかに何が要るかです。値だけで判断できるものは入口と値のクラスに、DB の状態が要るものは UseCase の中に置きます。UseCase に置けば、HTTP 以外の経路から呼んでも抜けません。さらに、集約の中の状態(確定前か、合計が上限以下か)は、確かめた後にも変わり得るので、更新と同じトランザクションの中で確かめ、楽観ロックの version で守ります。

例として、集約の記事の Order::addLine() を呼んで、注文に明細を足す API を作ります。Order・OrderLine・Money は、既存の記事の定義のまま使います。

FormRequest:形式の検証と、UseCase への入力の組み立て

use Illuminate\Foundation\Http\FormRequest;

final class AddOrderLineRequest extends FormRequest
{
    public function authorize(): bool
    {
        $order = OrderModel::findOrFail($this->route('orderId'));

        return $this->user()->can('update', $order);   // OrderPolicy::update() で注文の所有者と照合する
    }

    /** @return array<string, list<string>> */
    public function rules(): array
    {
        return [
            'sku' => ['required', 'string', 'regex:/\A[A-Z0-9-]{4,32}\z/'],
            'quantity' => ['required', 'integer', 'min:1', 'max:999'],
        ];
    }

    public function toCommand(): AddOrderLineCommand
    {
        return new AddOrderLineCommand(
            new OrderId($this->route('orderId')),
            $this->validated('sku'),
            (int) $this->validated('quantity'),
        );
    }
}

rules() には、値だけで判断できることしか書きません。max:999 は業務の上限ではなく、桁を打ち間違えたような明らかな誤入力を、項目のエラーとして早めに返すための上限です。業務として「1回の注文の合計は100万円まで」を守るのは Order です。

authorize() では、Policy で注文の所有者を確かめます。Laravel は FormRequest の authorize() を rules() の検証より先に呼ぶので、存在しない注文 ID はここで 404 になり、toCommand() の new OrderId() に届くのは実在する注文の ID だけです。authorize() も DB を読みますが、認可は業務ルールと違い、入口ごとに決めるものです(キューのジョブには利用者がいません)。そのため、FormRequest に置いています。

toCommand() は、検証済みの値から UseCase への入力を組み立てます。

final readonly class AddOrderLineCommand
{
    public function __construct(
        public OrderId $orderId,
        public string $sku,
        public int $quantity,
    ) {
    }
}

FormRequest は HTTP の層のクラスなので、内側の UseCase の型を知っていても依存の向きは崩れません。逆に、UseCase は FormRequest も $request->validated() の配列も知りません。

Value Object:入口の検証と重なっても、外さない

数量の「1以上」は、FormRequest の min:1 と OrderLine のコンストラクタの両方にあります。重複に見えますが、役割が違います。

  • FormRequest の min:1 は、利用者に「数量は1以上で入力してください」と返すためのもの
  • OrderLine の検証は、どの経路から作られても、数量0の明細が存在しないことを保証するためのもの

Value Object の記事で書いたとおり、FormRequest を通った値で Value Object の生成に失敗するなら、それは検証の漏れかプログラムの誤りです。422 に変換せず、500 としてログに残し、入口の検証を直します。

UseCase:状態に依存するルールは、更新と同じトランザクションで

interface ProductCatalog
{
    /** 販売中の商品の、いまの単価を返す。販売中でなければ ProductNotAvailable を投げる */
    public function currentPriceOf(string $sku): Money;
}

final class AddOrderLine
{
    public function __construct(
        private OrderRepository $orders,
        private ProductCatalog $catalog,
        private TransactionManager $transaction,
    ) {
    }

    public function execute(AddOrderLineCommand $command): void
    {
        $this->transaction->run(function () use ($command): void {
            // 注文を先に読み、状態との衝突(409)を、商品の販売状況(422)より先に返す
            $order = $this->orders->findOrFail($command->orderId);
            $price = $this->catalog->currentPriceOf($command->sku);

            // 確定前か・合計が上限以下かは Order が確かめる(OrderCannotBeModified・OrderTotalExceeded)
            $order->addLine(new OrderLine($command->sku, $command->quantity, $price));
            $this->orders->save($order);
        });
    }
}

UseCase の中では、$command->quantity が整数かどうかを確かめ直しません。形式の検証は入口の責任で、値の不変条件は OrderLine が守っています。UseCase が担うのは、DB の状態を読まないと判断できないルールだけです。

ドメインの例外を、入口でレスポンスに変換する

ドメインの例外は、HTTP のことを知りません。どのステータスとどの項目のエラーとして返すかは、Controller で決めます。

use Illuminate\Http\Response;
use Illuminate\Validation\ValidationException;

final class AddOrderLineController
{
    public function __construct(private AddOrderLine $useCase)
    {
    }

    public function __invoke(AddOrderLineRequest $request): Response
    {
        try {
            $this->useCase->execute($request->toCommand());
        } catch (ProductNotAvailable) {
            throw ValidationException::withMessages(['sku' => 'この商品は現在販売していません。']);
        } catch (OrderTotalExceeded) {
            throw ValidationException::withMessages(['quantity' => '注文の合計が上限を超えるため、追加できません。']);
        }

        return response()->noContent();
    }
}

ValidationException::withMessages() で投げると、FormRequest の検証エラーと同じ形の 422 になります。クライアントは、形式の誤りか業務ルールの違反かを区別せず、項目の横にメッセージを出すだけで済みます。

一方、確定済みの注文に明細を足そうとした OrderCannotBeModified は、入力の誤りではなく、注文の状態との衝突です。項目のエラーにはせず、ほかの状態遷移の例外と同じく、例外ハンドラで 409 に変換します。項目名(sku・quantity)はエンドポイントごとの都合なので、項目に結びつく変換は Controller に、エンドポイントに依らない変換は例外ハンドラに置く、という分け方です。

同じ注文に同時に明細を足して、save() が OrderWasModifiedConcurrently で失敗した場合は、利用者に返しません。明細の追加は今の状態だけで判断する操作なので、楽観ロックの記事の RetryOnConcurrentModification で UseCase の呼び出しを包み、読み直してやり直します(省略)。

HTTP 以外の入口には、その入口の検証を置く

CSV の取り込みから同じ AddOrderLine を呼ぶなら、取り込みの処理の中で Validator::make() を使って行ごとに形式を確かめ、「3行目の数量が数字ではない」のように返します。形式の検証を FormRequest から共通のクラスに抜き出したくなりますが、私は入口ごとに書くほうを選んでいます。エラーの返し方(項目ごとの 422、行番号つきの一覧、ジョブの失敗)が入口ごとに違い、検証のルールもその返し方と一緒に変わることが多いからです。どの入口から来ても、値の不変条件と業務ルールは内側で同じように守られます。

なぜそう判断したか

FormRequest に業務ルールを書かない理由

Rule::exists() や after() で DB を読む検証を FormRequest に書くと、確かめてから更新するまでの間に状態が変わり得ます。合計の上限は、同時に来た2つのリクエストがどちらも「上限以下」を確かめて保存すれば破れます。Order の中で確かめれば、楽観ロックの version で後から保存した方が失敗するので、上限が破られることはありません。

「販売中か」は事情が違います。商品は Order の集約の外にあるので、UseCase の中で確かめても、確かめた直後に販売が止まることは防げません。この点は FormRequest で確かめる場合と同じです。それでも UseCase に置くのは、次の段落の経路の理由と、単価を取るついでに確かめられるからです。明細を足した直後に販売が止まるのは許容し、止めたいなら注文の確定のときにもう一度確かめます。

もう1つは経路です。FormRequest は HTTP のリクエストにしか効きません。業務ルールを FormRequest に書くと、キューやコマンド、取り込みから呼んだときに、そのルールが黙って抜けます。

UseCase で Laravel のバリデータを使わない理由

UseCase の先頭で Validator::make() を呼ぶ方法もあります。HTTP 以外の経路でも検証が効く点は良いのですが、UseCase が Laravel のバリデータとメッセージの言語ファイルに依存し、業務ロジックを Laravel に依存しない場所に置く方針から外れます。入力も配列のままなので、検証済みかどうかが型に表れません。UseCase の入力は、検証済みであることが型で分かる Command と Value Object にしています。

FormRequest を使わず、ドメインの例外だけで返さない理由

ドメインのオブジェクトは、最初に見つけた違反で例外を投げます。それで正しさは守れますが、利用者は1つ直して送り直すたびに次のエラーを知ることになります。項目ごとの誤りをまとめて返すのは、入口の仕事です。入口でまとめて弾き、内側では1つでも違反があれば止める、という役割の分け方です。

どこから設計過剰になるか

  • 業務ルールのない管理画面の CRUD:FormRequest の exists や unique で済ませて構いません。UseCase も Command も要りません。ただし、一意であることは DB の一意制約でも守ります。unique の検証と INSERT の間には、同時に来たリクエストが割り込めるからです
  • すべてのエンドポイントに Command を作る:UseCase の引数が OrderId と int の2つ程度なら、Controller から直接渡せば足ります。項目が増えてきたら Command にまとめます
  • 形式の検証をすべて Value Object にも書く:DB の列の長さに合わせた文字数の上限や、メモ欄の文字数は、FormRequest と DB の制約で足ります。内側で保証したいのは、壊れていると業務の判断を誤る値(金額・数量・期間・ID)の不変条件だけです
  • 業務ルールのメッセージを FormRequest にも書いて事前に確かめる:画面で早めに知らせたい場合は、送信前の確認用の API を別に用意するほうが素直です。保存の経路で同じルールを2か所に書くと、片方だけ直される問題に戻ります

まとめ

  • 検証は「入力の形式」「値の不変条件」「状態に依存するルール」に分け、FormRequest・Value Object・UseCase とドメインに置く
  • 入口は項目ごとのエラーをまとめて返し、内側は検証済みの型を受け取って、1つでも違反があれば例外で止める
  • ドメインの例外は入口でレスポンスに変換し、項目に結びつくものは 422、状態との衝突は 409 にする

本記事の設計判断・事例・技術的見解は筆者によるものです。構成・文章編集には生成AIを利用しています。