フロントエンドとバックエンドで同じバリデーションルールを書いていませんか?「メールアドレスの形式チェック」「文字数制限」など、同じルールを 2 箇所で管理していると、どちらかを更新し忘れて不整合が起きる…というのはよくある話ですよね。
海外の DDD コミュニティでは「Single Source of Truth(信頼できる唯一の情報源)」という概念が重視されていて、バリデーションルールも 1 箇所に集約すべきとされています。今回は、Zod を使ってこれを実現する方法を紹介します。
アーキテクチャ全般については フロントエンドにクリーンアーキテクチャは必要か? も参考にしてください。
よくある問題:バリデーションが散らばる
典型的なプロジェクトだと、こんな感じになりがちです。
フロントエンド(React Hook Form + Zod)
const schema = z.object({
email: z.string().email(),
displayName: z.string().min(1).max(50),
});
バックエンド(Express/Hono)
const validateRequest = (req) => {
if (!req.body.email || !isValidEmail(req.body.email)) {
throw new Error('Invalid email');
}
if (!req.body.displayName || req.body.displayName.length > 50) {
throw new Error('Invalid displayName');
}
};
同じ「50 文字以内」というルールが 2 箇所に書かれています。これを海外では Anemic Domain Model(貧血ドメインモデル)と呼んでいて、ドメインのルールがあちこちに散らばっている状態を指します。
Value Objectでルールを集約する
解決策は、ドメイン層に Value Object としてルールを定義することです。
// packages/domain/src/profiles/profile-display-name.ts
import { z } from "zod";
const rules = z
.string()
.min(1)
.max(50)
.brand("ProfileDisplayName");
export type ProfileDisplayName = z.infer<typeof rules>;
export const ProfileDisplayName = {
rules,
create: (value: string): ProfileDisplayName => {
return value as ProfileDisplayName;
},
};
ポイントは 3 つあります。
rules: Zod スキーマそのもの。バリデーションルールの本体create: ファクトリ関数。値を生成するbrand: TypeScript の型を一意にする(stringと区別できる)
これでバリデーションルールが 1 箇所に集約されました。
エラーメッセージはどこで定義するか
Value Object のrulesには、ユーザー向けのエラーメッセージを含めないのがポイントです。
理由はシンプルで、エラーメッセージは表示層の関心事だからです。ドメイン層は「50 文字以内」というルールだけを持ち、それをどう伝えるかは各層に任せます。
- フロントエンドでは日本語でユーザーにわかりやすく表示する
- バックエンドでは API エラーレスポンスとして JSON 形式で返す
- 別の言語対応が必要になったら多言語化も容易
フロントエンドでは、フィールドファクトリでエラーメッセージを設定します。
export const profileDisplayNameField = createFormFieldFactory(
ProfileDisplayName,
{
messages: ({ type }) => {
if (type === 'too_small') return '表示名を入力してください';
if (type === 'too_big') return '表示名は50文字以内で入力してください';
return '表示名が無効です';
},
}
);
バックエンドでも同様に、API スキーマ定義時にエラーメッセージをカスタマイズできます。
フォームフィールドを宣言的に定義する
ここからが本題です。Value Object を定義したら、フロントエンドのフォームでどう使うか。
まず、Value Object からフォームフィールドを生成するファクトリを作ります。
// フィールドファクトリを定義
export const profileDisplayNameField = createFormFieldFactory(
ProfileDisplayName,
{
messages: ({ type }) => {
if (type === 'too_small') return '表示名を入力してください';
if (type === 'too_big') return '表示名は50文字以内で入力してください';
return '表示名が無効です';
},
}
);
すると、フォームスキーマがこう書けるようになります。
// フォームスキーマ(たった3行)
export const profileFormSchema = createFormSchema({
displayName: profileDisplayNameField({ required: true }),
bio: profileBioField({ required: false }),
color: profileColorField({ required: true }),
});
これ、かなり宣言的じゃないですか?
required: trueかfalseかを指定するだけで、同じ Value Object から必須フィールド・任意フィールドを切り替えられます。バリデーションルールはProfileDisplayName.rulesから自動で引き継がれるので、重複はゼロです。
バックエンドでも同じルールを使う
バックエンド(Supabase Edge Functions / Hono)でも、同じ Value Object を参照します。
// supabase/functions/_shared/schemas/create-issue-params.ts
import { z } from "zod";
import { IssueTitle } from "@ralie/domain/issues/issue-title.ts";
import { IssuePriority } from "@ralie/domain/issues/issue-priority.ts";
export const CreateIssueParamsSchema = z.object({
projectId: ProjectId.rules,
title: IssueTitle.rules, // ← ドメインのrulesを直接使用
priority: IssuePriority.rules.optional(),
});
API エンドポイントではzValidatorで検証します。
app.post(
'/',
authWithUserMiddleware,
zValidator('json', CreateIssueParamsSchema), // ← これだけ
async (c) => {
const validatedData = c.req.valid('json');
// ...
}
);
フロントエンドで「50 文字以内」と定義したら、バックエンドでも自動的に「50 文字以内」になります。ルールの不整合は原理的に発生しません。
ディレクトリ構成
モノレポでの構成はこんな感じです。
packages/
domain/
src/
issues/
issue-title.ts # Value Object
issue-status.ts # enumのValue Object
profiles/
profile-display-name.ts
apps/
mobile/
src/
forms/
fields.ts # フィールドファクトリ定義
profile-form.ts # フォームスキーマ
libs/
form/
schema.ts # createFormFieldFactory
supabase/
functions/
_shared/
schemas/
create-issue-params.ts # APIスキーマ
packages/domainがフロントエンド・バックエンド両方から参照される「Single Source of Truth」になっています。
enumの場合
ステータスのような enum も同じパターンで定義できます。
// packages/domain/src/issues/issue-status.ts
export const IssueStatusValues = [
"open",
"in_progress",
"resolved",
"closed",
] as const;
const rules = z.enum(IssueStatusValues).brand("IssueStatus");
export type IssueStatus = z.infer<typeof rules>;
export const IssueStatus = {
values: IssueStatusValues,
rules,
create: (value: string): IssueStatus => { /* ... */ },
// ドメインロジックもここに
Open: "open" as IssueStatus,
InProgress: "in_progress" as IssueStatus,
isActive: (status: IssueStatus): boolean => {
return status === "open" || status === "in_progress";
},
canTransitionTo: (from: IssueStatus, to: IssueStatus): boolean => {
// 状態遷移のルールを定義
const transitions: Record<string, IssueStatus[]> = {
open: [IssueStatus.InProgress, IssueStatus.Resolved],
// ...
};
return transitions[from]?.includes(to) || false;
},
};
バリデーションルールだけでなく、ドメインロジック(isActive、canTransitionToなど)も同じ場所に集約できます。これが DDD でいう「リッチドメインモデル」ですね。
実際に得られたメリット
この構成にしてから、いくつか明確なメリットがありました。
1. バリデーションルールの変更が1箇所で済む
「50 文字」を「100 文字」に変えたいとき、profile-display-name.tsだけ修正すれば OK。フロントエンドもバックエンドも自動で追従します。
2. フォーム定義が読みやすい
displayName: profileDisplayNameField({ required: true }),
この 1 行で「何のフィールドか」「必須かどうか」「バリデーションルール」が全部わかります。
3. 型安全
ProfileDisplayName型とstring型は区別されるので、誤って生の文字列を渡すとコンパイルエラーになります。
注意点
いくつか注意点もあります。
ビルド設定が必要
モノレポでパッケージを共有するには、TypeScript のパス設定やビルド設定が必要です。最初のセットアップはちょっと面倒かもしれません。
過度な抽象化に注意
すべてのフィールドを Value Object にする必要はありません。単純なstringやnumberはそのままでいいケースもあります。ドメイン的に意味のある値(メールアドレス、ユーザー名、ステータスなど)に絞るのがおすすめです。
まとめ
Zod で Value Object を定義し、フロントエンドのフォームとバックエンドの API バリデーションを一元管理する方法を紹介しました。
海外の DDD コミュニティでは「Single Source of Truth」として知られているパターンですが、Zod を使うとかなりシンプルに実現できます。フォームフィールドファクトリを作れば、宣言的な記述でフォームを定義できるのも便利です。
モノレポで開発しているなら、導入コストは低い。