本文へ移動
🧭

API のバリデーションは Zod とドメイン層のどちらに書くか

14分
もくじ

バックエンドの 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 の入口routes/issues.ts ドメイン層domain/issueTitle.ts

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 は、課題を作成できたことを表す番号である。

いいえ はい はい いいえ リクエストで届いた題名 API の入口の Zod題名は文字列か 400 を返す ドメイン層の関数空白を除くと空か 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);

新しい題名が届いたときだけドメイン層の関数に渡し、届いていなければ今の題名をそのまま使う。

課題を作成する操作 parseIssueTitle題名は空にできない 課題を更新する操作

更新でも、空白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つが食い違う日は来ない。

おわり😊

RELATED

つくることで、見える景色がある。