API のバリデーションは Zod とドメイン層のどちらに書くか
もくじ
バックエンドの API のコードは、届いた値を受け取る「API の入口」と、業務ルールを書く「ドメイン層」に分けて書くことがある。「題名は空にできない」のような業務ルールは、本来ドメイン層に書く。ところが、API の入口で使う Zod にも、同じ業務ルールが書けてしまう。両方に書くと、片方だけ変えた日から、2つは食い違う。
バックエンドは、サーバーの側で動いて、求められたデータを用意するプログラムだ。API は、バックエンドのうち、アプリやブラウザーからの求めを受け取って、データを返す部分だ。
業務ルールは、「題名は空にできない」のように、そのアプリがそう決めたことを指す。API は、届いた値をデータベースに保存する前に、保存してよい値かどうかを確かめる。これをバリデーションと呼ぶ。
Zod は、バリデーションに使う TypeScript のライブラリだ。「題名は文字列である」のように値がどうなっていればよいかを書いておくと、届いた値がそのとおりかを確かめる。
だから、書く場所を1つに決める。API の入口の Zod には「題名は文字列である」という値の形だけを書く。「題名は空にできない」という業務ルールは、ドメイン層の関数1つにだけ書く。
API のコードは、API の入口とドメイン層に分かれている
課題を管理するアプリの API を例にする。課題は「ログインできない」のような題名を持つ。
バリデーションで確かめることは、2種類に分けられる。1つめは値の形だ。題名が文字列であるか、数値や配列ではないか、を確かめる。2つめは業務ルールだ。題名は空にできない、のように、このアプリがそう決めたことを確かめる。
コードは2つのフォルダーに分ける。値の形は API の入口で確かめ、業務ルールはドメイン層で確かめる。
src/
├── routes/
│ └── issues.ts API の入口。値の形を確かめる(業務ルールも書けてしまう)
└── domain/
└── issueTitle.ts ドメイン層。業務ルールは本来ここに書く
アプリやブラウザーが API に送る求めを、リクエストと呼ぶ。リクエストは、まず API の入口を通り、次にドメイン層に届く。
API の入口で使う Zod は、「文字列である」だけでなく「1文字以上である」も書ける。だから「題名は空にできない」は、domain/issueTitle.ts だけでなく、routes/issues.ts にも書けてしまう。
会社の経費精算に置き換えると、こうなる。申請書を受け取る窓口の担当者は、金額の欄が埋まっているか、数字として読めるかを確かめる。これが値の形にあたる。「会食は1人5,000円まで」という社内のルールに合っているかは、経理の担当者が確かめる。これが業務ルールにあたる。窓口が API の入口で、経理がドメイン層だ。
窓口の担当者も「5,000円まで」と書いた紙を持ち、同じ社内のルールを確かめることはできる。ただし、上限が8,000円に変わったのに窓口の紙が古いままだと、経理なら通す申請を、窓口が断ってしまう。
悪い例: 同じ業務ルールを API の入口とドメイン層の両方に書く
API の入口の Zod に、こう書いたとする。
// src/routes/issues.ts
const createIssueBody = z.object({ title: z.string().min(1) });
z.string() は「文字列である」、min(1) は「1文字以上である」という意味だ。空の題名は API の入口で断れる。
ドメイン層にも、同じ業務ルールを書く。
// src/domain/issueTitle.ts
export class InvalidIssueTitleError extends Error {}
export function parseIssueTitle(raw: string): string {
const title = raw.trim();
if (!title) throw new InvalidIssueTitleError("題名は空にできません");
return title;
}
trim() は前後の空白を除く。除いた結果が空なら、InvalidIssueTitleError というエラーを出して処理を止める。空でなければ、空白を除いた題名を返す。
どちらも「題名は空にできない」のつもりで書いた。ところが、中身が少し違う。min(1) は文字数を数えるので、空白3つの題名を「3文字ある」として通す。ドメイン層の関数は、空白を除いてから空かどうかを確かめるので、同じ題名を断る。z.string().min(1) に、空の文字列と空白3つを渡した結果がこれだ(true は、Zod が通したことを表す)。
"" -> false
" " -> true
書いた時点で、2つはもう食い違っている。
Zod でも z.string().trim().min(1) と書けば、空白を除いてから数えるので、ドメイン層の関数と同じ結果になる。
"" -> false
" " -> false
ただ、それは2か所を同じ中身に揃え続けるということだ。この先「題名は100文字まで」を足すときも、2か所を同じように変えなければならない。経費精算でいえば、窓口の紙と経理のルールを、いつも同じ中身にしておくことにあたる。片方だけ変えた日から、どちらが正しいのか分からなくなる。
良い例: API の入口の Zod には値の形だけを書き、業務ルールはドメイン層の関数1つに書く
API の入口は、Zod で値の形を確かめてから、ドメイン層の関数を呼ぶ。
// src/routes/issues.ts
import { z } from "zod";
import { InvalidIssueTitleError, parseIssueTitle } from "../domain/issueTitle.ts";
const createIssueBody = z.object({ title: z.string() });
export function createIssue(body: unknown) {
const parsed = createIssueBody.safeParse(body);
if (!parsed.success) {
return { status: 400, error: "値の形が正しくありません" };
}
try {
const title = parseIssueTitle(parsed.data.title);
return { status: 201, title };
} catch (error) {
if (error instanceof InvalidIssueTitleError) {
return { status: 400, error: error.message };
}
throw error;
}
}
Zod から min(1) を外した。Zod は、題名が文字列であることだけを確かめる。空かどうかは確かめない。「題名は空にできない」は、悪い例と同じドメイン層の関数 parseIssueTitle にだけ書いてある。2つめの import の行で、その関数を API の入口から呼べるようにしている。
createIssue は、課題を作成する操作だ。body は、リクエストで届いた値である。例を短くするため、HTTP の通信とデータベースに保存する処理は省き、応答の番号を status に入れて返している。safeParse は、届いた値が Zod に書いた値の形に合っているかを確かめ、結果を success に入れて返す。合っていなければ、400 を返す。400 は、リクエストの内容が誤っていることを表す HTTP の番号だ。合っていれば、題名をドメイン層の関数に渡す。ドメイン層の関数がエラーを出したら、それも 400 にして返す。201 は、課題を作成できたことを表す番号である。
題名に、数値、空白3つ、前後に空白の付いた文字列を渡した結果がこれだ。
createIssue({ title: 123 })
{ status: 400, error: '値の形が正しくありません' }
createIssue({ title: " " })
{ status: 400, error: '題名は空にできません' }
createIssue({ title: " ログインできない " })
{ status: 201, title: 'ログインできない' }
数値は API の入口の Zod が断り、空白3つはドメイン層の関数が断った。どちらが断ったのかは、応答の error から分かる。
なぜそれでよいのか
値の形と業務ルールは、変わり方が違う。題名が文字列であることは、変わりにくい。業務ルールのほうは、アプリの使われ方に合わせて変わる。明日「題名は100文字まで」が増えるかもしれない。変わるものが2か所にあると、変えるたびに食い違うおそれがある。だから、書く場所は1つにする。
課題を更新する操作を足すと、違いが見える。API の入口の Zod に書く値の形は、操作ごとに1つ増える。
// src/routes/issues.ts
const createIssueBody = z.object({ title: z.string() });
const updateIssueBody = z.object({ title: z.string().optional() });
optional() は、その項目が無くてもよい(undefined でもよい)という意味だ。更新では、題名を変えないリクエストも届くからである。
一方、業務ルールを確かめる関数は増えない。課題を更新する操作も、同じ parseIssueTitle を呼ぶ。
// src/routes/issues.ts
const title =
parsed.data.title === undefined
? current.title
: parseIssueTitle(parsed.data.title);
新しい題名が届いたときだけドメイン層の関数に渡し、届いていなければ今の題名をそのまま使う。
更新でも、空白3つの題名は同じ関数が断る。
updateIssue(current, { title: " " })
{ status: 400, error: '題名は空にできません' }
「題名は100文字まで」を足す日が来たら、変えるのは domain/issueTitle.ts の parseIssueTitle だけだ。課題を作成する操作も、課題を更新する操作も、何も変えなくても新しい業務ルールに従う。
業務ルールを API の入口の Zod にだけ書く案もある。その場合は、操作の数だけ、同じ業務ルールを Zod に書くことになる。
| 業務ルールを書く場所 | 書く数 | 「題名は100文字まで」を足すときに変える場所 |
|---|---|---|
| API の入口の Zod とドメイン層の両方(悪い例) | 操作の数と、関数1つ | 操作の数だけの Zod と、関数1つ |
| API の入口の Zod だけ | 操作の数 | 操作の数だけの Zod |
| ドメイン層の関数1つ(良い例) | 1つ | 関数1つ |
この分け方で諦めるもの
諦めるものは2つある。
1つめは、空の題名を API の入口で断ることだ。空白3つの題名は Zod を通り、ドメイン層の関数まで進んでから断られる。
2つめは、呼び忘れを TypeScript に止めてもらうことだ。新しい操作が parseIssueTitle を呼び忘れても、題名の型は string のままなので、TypeScript はエラーにしない。呼び忘れは、テストやコードレビューで見つけることになる。題名を専用の型にして、ドメイン層の関数を通った値だけがその型になれるようにすれば、呼び忘れは TypeScript が止める。ただし、題名を受け渡す場所の型も書き換えることになる。
それでも僕は、書く場所を1つにするほうを選ぶ。2つが食い違っていても、プログラムはエラーにならず、そのまま動いてしまうからだ。
書けることと、書く場所であることは別だ
Zod は「題名は空にできない」も書ける。だからといって、API の入口がその業務ルールを書く場所だとは限らない。API の入口の Zod には値の形だけを書き、業務ルールはドメイン層の関数1つに書く。同じ業務ルールを2か所に書かなければ、2つが食い違う日は来ない。
おわり😊

