Zodでエンティティを定義してみて感じたメリットとデメリット
Zodスキーマをドメイン層のエンティティとして使うと何が嬉しくて何が辛いのか。実際に使ってみて感じたことをまとめました。
Zodでエンティティを定義するようになりました。最初は「型が自動で出るの最高」と思っていたのですが、使い込むうちに「ここはクラスの方が楽だったな」と感じる場面も出てきました。良いところも辛いところも見えてきたので、一度整理してみます。
※ Zodをドメイン層に置く基本的な設計についてはDomain層のZodスキーマを起点にした型安全設計で書いています。
どう使っているか
ドメイン層にZodスキーマでエンティティと値オブジェクトを定義しています。
// 値オブジェクト
export const EventTitle = z
.string()
.min(1)
.max(100)
.brand("EventTitle");
export type EventTitle = z.infer<typeof EventTitle>;
// エンティティ
export const Event = z.object({
id: EventId,
title: EventTitle,
date: EventDate,
description: EventDescription.optional(),
});
export type Event = z.infer<typeof Event>;
z.infer<typeof Event> で型が導出されるので、スキーマと型の二重管理が不要になります。
メリット
バリデーションと型定義の一元化
これが一番大きいです。従来だと型定義とバリデーションロジックを別々に書く必要がありました。
// 従来の方法
type Event = {
title: string;
date: Date;
};
function validateEvent(data: unknown): Event {
// 手動でバリデーション...長い
}
Zodならスキーマ定義がそのままバリデーションになります。スキーマを変えれば型も検証ロジックも同時に変わるので、「型と実装がずれる」ということが起きません。
スキーマ合成で派生型を簡潔に作れる
.pick(), .partial(), .extend() などで、1つのベースから複数の派生スキーマを作れます。
// ベースのエンティティ
export const Event = z.object({
id: EventId,
title: EventTitle,
date: EventDate,
description: EventDescription.optional(),
});
// PATCH用の入力型(全フィールドをオプショナルに)
export const EventPatchInput = Event.pick({
title: true,
date: true,
description: true,
}).partial();
// 作成時の入力型(idは自動生成なので除外)
export const EventCreateInput = Event.omit({ id: true });
同じ情報を何度も書かなくて済むので、変更時の修正漏れが減ります。
Brand型でプリミティブを型安全に
.brand() を使うと、ただの string ではなく固有の型として扱えます。
export const UserId = z.uuid().brand("UserId");
export const FamilyId = z.uuid().brand("FamilyId");
// コンパイル時にエラーになる
function getUser(id: UserId) { ... }
getUser(familyId); // 型エラー!
IDの取り違えをコンパイル時に検出できます。地味にありがたいです。
APIスキーマとの統合が自然
Hono + @hono/zod-openapi を使っているのですが、ドメイン層で定義したZodスキーマをそのままAPI定義に使えます。
// routes層
import { Event, EventPatchInput } from "@myapp/domain";
const route = createRoute({
responses: {
200: { content: { "application/json": { schema: Event } } },
},
});
OpenAPI仕様も自動生成されますし、リクエスト/レスポンスの検証も自動です。ドメイン層の型がAPIまで一気通貫で使えるのは快適です。
プレーンオブジェクトのシンプルさ
Zodで作られるエンティティはただのプレーンオブジェクトです。シリアライズ/デシリアライズがそのまま動きます。
const event = createEvent({ ... });
JSON.stringify(event); // そのまま動く
テストでモックを作るのも簡単です。クラスだと new Event() したりメソッドをモックしたり面倒になりがちです。
デメリット
振る舞い(メソッド)を持てない
これが一番辛いところです。クラスなら自然に書けるメソッドが、Zodでは書けません。
// クラスなら
class Event {
isOverdue(): boolean {
return this.date < new Date();
}
}
event.isOverdue(); // 呼べる
// Zodだと
const Event = z.object({ ... });
event.isOverdue(); // そんなメソッドはない
ヘルパー関数で代替することになります。
function isEventOverdue(event: Event): boolean {
return event.date < new Date();
}
isEventOverdue(event);
動作としては同じですが、IDEで event. と打っても候補に出てきません。関連する関数を知っている必要があるのが微妙に不便です。
不変性の保証がない
プレーンオブジェクトなので、フィールドを直接書き換えられてしまいます。
const event = createEvent({ ... });
event.title = "書き換えちゃった"; // 防げない
クラスなら private フィールドとgetterで制御できます。Zodの場合は規約で縛るか、Object.freeze などで対処するしかありません。
Zodへの依存
ドメイン層がZodというライブラリに依存することになります。純粋なドメインロジックがライブラリに縛られるのは、クリーンアーキテクチャ的にはちょっと気になるところです。
とはいえ、Zodは安定していますし、メリットの方が大きいと思って割り切っています。
デメリットへの対処:transformで振る舞いを追加する
「メソッドを持てない」問題への対処法として、.transform() を使う方法があります。
Getter型
const Event = z.object({
date: EventDate,
title: EventTitle,
}).transform((data) => ({
...data,
get isOverdue() {
return data.date < new Date();
},
}));
アクセスするたびに計算されるので、常に最新の値が返ります。ただし JSON.stringify() するとgetterは消えてしまいます。
固定値型
const Event = z.object({
date: EventDate,
title: EventTitle,
}).transform((data) => ({
...data,
isOverdue: data.date < new Date(),
formattedDate: data.date.toLocaleDateString('ja-JP'),
}));
parse時に計算して固定値として埋め込みます。JSON.stringify() しても残るので、APIレスポンスにそのまま使えます。
どっちを使うか
| 観点 | Getter型 | 固定値型 |
|---|---|---|
| 評価タイミング | アクセス時 | parse時 |
| シリアライズ | 消える | 残る |
| 現在時刻依存の値 | 常に最新 | parse時点で固定 |
| パフォーマンス | アクセスごとに計算 | 1回のみ |
APIレスポンスに含めたい値や、入力から一意に決まる値(fullName、subtotal など)は固定値型がおすすめです。リアルタイム性が必要なケースではGetter型を使う感じです。
実際のところ、多くのケースでは固定値型で十分だと思います。Webアプリではリクエストごとにparseし直すので、「parse時点で固定」でも問題になることは少ないです。
どういうプロジェクトに向いているか
Zodをエンティティに使うのは「アリ」だと思っています。特に以下のようなプロジェクトには向いています。
- APIとフロントエンドで型を共有したい
- バリデーションロジックを一元管理したい
- ドメインロジックがそこまで複雑でない
逆に、エンティティに複雑な振る舞いを持たせたいケースや、DDDを本格的にやりたいケースでは、クラスベースの方が素直かもしれません。
トレードオフを理解した上で選べば、結構快適に開発できます。