1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TSの鬼 第18回:型安全 CQRS—Command と Query を完全分離する設計

Posted at

はじめに

前回

ドメインが成長すると「読み取り(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);
}
  • dispatchcmd.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 マッパの型生成を活用したスキーマ進化戦略を解説する。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?