FormRequestとUseCaseで入力検証をどう分担するか
この記事で扱う問題
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 にする