Zodは信用の境界であってビジネスルールの番人ではない

「バリデーションはZodでやっている」という言い方は、業務システムの文脈では実は不正確です。Zodが保証してくれるのは「送られてきたデータが期待した型・形式をしているか」までで、「その値が業務上正しいか」までは保証してくれません。この違いを曖昧にしたまま実装すると、型は通っているのに業務的にはあり得ない値がそのままDBに書き込まれる、という事故につながります。BtoB受発注システムのカート機能を例に、この境界線を見ていきます。

カートの価格はZodで検証されているのに、使われない

チェックアウトフォームが、カートの中身をJSON文字列としてサーバーに送信するケースを考えます。受け取り側のZodスキーマはこうなっています。

// schemas/order.ts
const cartItemSchema = z.object({
  id: z.string(),
  qty: z.number().int().min(1),
  price: z.number().min(0),
  tax_rate: z.number().min(0).max(1),
  name: z.string(),
  sku: z.string(),
});

pricetax_rateもきちんと型・範囲が定義されており、一見するとこれで検証が完結しているように見えます。ところが実際のチェックアウト処理を見ると、Zodを通過したこのpriceはどこにも使われていません。

// routes/cart/+page.server.ts
for (const item of items) {
  const product = productMap.get(item.id);
  // ...
  const groupPrice = priceGroupId
    ? product.group_prices.find((gp) => gp.price_group_id === priceGroupId)
    : null;
  const unit_price = groupPrice
    ? groupPrice.price
    : discountRate !== null
      ? Math.floor(product.base_price * discountRate)
      : product.base_price;

  const subtotal = unit_price * quantity;
  const item_tax = Math.floor(subtotal * product.tax_rate);
  // ...
}

注文明細として実際にDBへ書き込まれるunit_pricetax_rateは、フォームから送られてきた値ではなく、サーバー側でDBから引き直した商品マスタと、買い手が属する契約価格・掛率から、その場で再計算した値です。クライアントが送ってきたpriceフィールドは、Zodのバリデーションを通過した後、静かに捨てられています。

なぜこの設計が正しいのか

この一見「無駄に見える」処理は、BtoB受発注システムにとっては必須の防御です。カートのpriceは、ブラウザの開発者ツールやAPIを直接叩くことで誰でも書き換えられる値です。Zodは「priceという名前のフィールドが数値で、0以上であるか」は保証しますが、「その数値が本当にこの商品の正しい価格か」は一切関知しません。もしサーバー側がこのクライアント由来のpriceをそのまま注文金額として扱っていたら、悪意のあるリクエストで実質的にいくらでも安く発注できてしまいます。

つまりZodスキーマがここで担っているのは「これは処理を続けてよい形をした入力か」という入力境界のチェックだけであり、「この価格は正しいか」という業務上の真実は、ドメイン・アプリケーション層がDBの商品マスタを再計算の起点にすることでしか担保できません。Zodの検証を通過したことと、その値を信用してよいことは、まったく別の話です。

それぞれのレイヤーが答えている問いは違う

この設計を俯瞰すると、実質的に3つのレイヤーが、それぞれ異なる問いに答えていることが分かります。

  • Zod(入力境界): 「このリクエストは解釈可能な形をしているか」——型・必須項目・形式的な範囲チェック。priceが数値であることは検証するが、その数値が正しいかまでは知らない。
  • アプリケーション/ドメイン層(チェックアウト処理そのもの): 「この値は信用してよいか、業務ルール上正しいか」——価格は必ずDBの商品マスタと契約条件から再計算し、在庫数量はDBの現在値と突き合わせて検証する。クライアントの自己申告はここでは一切信用しない。
  • DBスキーマ/制約: 「この状態はデータとして矛盾していないか」——NOT NULL・外部キー制約のような構造的な整合性の担保。個々のリクエストの意図の正しさまでは検証しない。

この3つは代替関係ではなく補完関係です。Zodを手厚くしても価格改ざんは防げませんし、DB制約を強めても「送られてきた注文数量が在庫を超えていないか」のような動的な業務ルールはチェックできません。逆にドメイン層だけですべてやろうとすると、明らかに不正な形式のリクエストまでドメインロジックの奥深くまで通してしまい、無駄な処理とエラーハンドリングの複雑化を招きます。

実務での見分け方

新しい入力を扱うコードを書くときに便利な問いが1つあります。「この値は、クライアントの自己申告のまま信用していいものか、それともサーバー側の別の情報源と突き合わせて確定させるべきものか」です。ユーザーが入力した氏名や備考欄のようなテキストは前者で、Zodの検証だけで十分なことがほとんどです。一方、価格・在庫数・権限・所有者IDのように、業務上の結果や金銭・セキュリティに直結する値は後者であり、Zodを通過した後でも、ドメイン層でDBの現在の状態を起点に再計算・再検証する必要があります。上で見たカート処理は、この境界線をコードの形でそのまま体現している好例だと言えます。