はじめに
前回
ドメインが成長すると「読み取り(Query)と書き込み(Command)が衝突してスキーマが歪む」問題が顕在化する。CQRS (Command Query Responsibility Segregation) はこの衝突を分離して解消するアーキテクチャである。本稿では TypeScript の型システムを活用し、コンパイル時に CQ と RS の境界を保証する 実装パターンを解説する。
1. なぜ CQRS か?
課題 | CQRS 導入効果 |
---|---|
複雑なリソースごとの if/else 地獄 | 読み取りモデルと書き込みモデルを別定義し、責務を分離 |
バックエンドスケールの限界 | Query をリードレプリカ、Command をマスターで水平分割 |
型の重複と崩壊 | Command 型と Query 型を別ファイルで厳格管理 |
2. 基本の型定義
/** Command サイド */
export type CreateArticleCmd = {
title: string;
body: string;
authorId: number;
};
export type UpdateArticleCmd = {
id: number;
title?: string;
body?: string;
};
type Command =
| { type: "CreateArticle"; payload: CreateArticleCmd }
| { type: "UpdateArticle"; payload: UpdateArticleCmd };
/** Query サイド */
export type ArticleDTO = {
id: number;
title: string;
body: string;
author: string;
};
- Command 型は 意図 (type) とデータ (payload) を必ず持つ。
- Query 型はプレゼンテーション最適化された DTO として独立管理する。
3. Command バスの型安全実装
type CommandHandler<C extends { type: string; payload: any }> = (
cmd: C["payload"]
) => Promise<void>;
const handlers: Record<string, CommandHandler<any>> = {
CreateArticle: async (p: CreateArticleCmd) => {
await db.article.create(p);
},
UpdateArticle: async (p: UpdateArticleCmd) => {
await db.article.update({ where: { id: p.id }, data: p });
},
};
export async function dispatch<C extends Command>(cmd: C): Promise<void> {
const handler = handlers[cmd.type];
if (!handler) throw new Error("No handler");
await handler(cmd.payload);
}
-
dispatch
はcmd.type
に対応したペイロード型のみ受け付け、不一致はコンパイルエラー。
4. Query リポジトリの型安全化
import { z } from "zod";
const ArticleDTOSchema = z.object({
id: z.number(),
title: z.string(),
body: z.string(),
author: z.string(),
});
export type ArticleDTO = z.infer<typeof ArticleDTOSchema>;
export async function getArticle(id: number): Promise<ArticleDTO> {
const raw = await db.article.findUnique({
where: { id },
include: { author: true },
});
return ArticleDTOSchema.parse({
id: raw.id,
title: raw.title,
body: raw.body,
author: raw.author.name,
});
}
- Query は DTO スキーマ→型推論 でランタイム・コンパイル両面を保証。
5. フロントエンド統合(React Query)
const useArticle = (id: number) =>
useQuery(["article", id], () => getArticle(id));
const createArticle = () =>
useMutation((p: CreateArticleCmd) => dispatch({ type: "CreateArticle", payload: p }), {
onSuccess: () => queryClient.invalidateQueries(["articleList"]),
});
- Command と Query が 異なる型・異なるエンドポイント で扱われるため、IDE 補完の衝突がない。
6. 落とし穴と対策
落とし穴 | 原因 | 対策 |
---|---|---|
Command 型が肥大化 | 1 型に多操作 | Command を 1 usecase = 1 type に分割 |
DTO とビジネスモデル混同 | Query 側でドメインエンティティを直接返す | DTO スキーマで整形し、依存関係を切る |
クライアントで Command/Query 誤用 | 共有型を不用意に import | Barrel export で Command と Query を別ファイルにまとめ、ESLint 禁止規則を追加 |
7. CQ と RS のパフォーマンス分離
- Command DB: 書き込み最適化 (正規化モデル、強整合)
- Query DB: 読み取り最適化 (リードレプリカ or キャッシュ、非正規化ビュー)
- TypeScript 型は DB スキーマではなく API/DTO スキーマ を基準に定義し、物理層変更の影響を軽減する。
まとめ
- Command と Query を 型レベルで分離 し、役割衝突を防止。
-
dispatch
×CommandHandler
で ペイロード型を静的保証 し、誤呼び出しを排除。 - DTO スキーマと Zod で ランタイム整合性 も担保し、バックエンド変更に強いフロントを構築できる。
次回は データベースマイグレーションと型安全 を扱い、 Prisma/Drizzle など OR マッパの型生成を活用したスキーマ進化戦略を解説する。