3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Next.js × Prisma】「型定義の爆発」を防ぐ、CQRS風なプロジェクト構成の提案

3
Last updated at Posted at 2026-01-27

はじめに

Next.js(App Router)× Prisma の構成は近年のWebアプリ開発ではよく見る構成かと思います。

ただプロジェクトが育ってくると、とある問題にぶつかります。

  • 画面ごとに最適なデータを取得するためにincludeselect が増える
  • すると WithXxx 型が増殖する
  • 結果的にデータフェッチ層 が「画面都合のメソッド」だらけになる
// 画面ごとの些細な違いで型が無限に増殖する
type UserWithPosts = ...
type UserWithProfileAndSettings = ...
type UserWithRoleAndDepartment = ...

この苦しみから逃れるために 今回は CQRS というの考えに基づいたNext.jsプロジェクト構成をご提案させていただければと思います。

厳密な CQRS(イベントソーシング等)ではなく、あくまで「 CQRS 的な責務分離の考えをディレクトリ構成に落としてみた」的なゆるい感じなので予めご了承ください。

CQRSとは

まずは今回の肝となる CQRS についてざっくり解説します。

CQRS は日本語にすると「コマンドクエリ責務分離」となります。
つまり何かを「コマンド」と「クエリ」に分解しようぜという考え方になります。

では何を分解しているのか。
それは CRUD(Create, Read, Update, Delete) の概念そのものになります。

普段我々は何気なく CRUD と一緒くたにして呼んでいますが、注目したいのは Read と他の3つではデータの取り扱いに対するポリシーがかなり異なるという点です

Create, Update, Deleteの関心

  • ドメインルールは絶対
  • バリデーションしたい
  • トランザクションも必要
  • 型はできるだけ厳密に

要するにデータはピュアなモデルのまま厳密に扱いたいという考え。

Readの関心

  • モデルとか関係なく必要なデータが欲しい
  • オーバーフェッチしたくない
  • キャッシュを効かせたい
  • N+1問題などもってのほか

こちらはモデルとか関係なく必要なデータを効率的に持ってきたいという考え。

このように性質の相反する要素を CRUD という名目上で混同して扱うのは難しいということです。混ぜるな危険です。

なので Create, Update, Delete をコマンド(更新系)、Readをクエリ(参照系) と明確に分けて取り扱おうというのが CQRS の基本的な考え方というわけです。

考案したディレクトリ構成

それでは本題の、CQRS 的な考えに基づいた Next.js のプロジェクト構成について説明していきます。

コアとなる部分

コマンド(更新系)

  • domain/ でモデル、およびインターフェースを定義
  • ここでは Prisma の selectinclude でモデルを改変することは禁止
  • repositories/ で定義されたインターフェースを実装

クエリ(参照系)

  • features/types.ts 内でそれぞれの feature 固有のインターフェースを定義
  • ここではドメインモデルを無視した柔軟なデータ型を使用可能。ただし feature 名で区切られた名前空間でのみ使用可能。(外に出すことはできない)
  • types.ts で定義したインターフェースを queries.ts で実装

ディレクトリ構成例

root/
└── src/
    ├── app/
    ├── features/
    │   └── {{ feature名 }}/
    │       ├── components/ # そのFeature専用のコンポーネント
    │       ├── queries.ts  # そのFeature専用のインターフェースの実装
    │       └── types.ts    # そのFeature専用のインターフェースの定義
    ├── domain/             # ドメイン層
    │   └── models/         # ドメインモデル
    │   └── interfaces/     # Repositoryインターフェース(コマンド)
    ├── repositories/       # コマンドの実装
    ├── components/         # 共通UIコンポーネント
    ├── lib/                # 外部サービス連携のライブラリ
    ├── hooks/              # Reactカスタムフック
    ├── utils/              # ユーティリティ関数
    └── styles/             # グローバルスタイル

具体的な実装例

domain/models

例: User.ts

import { User as PrismaUser } from "@prisma-generated/client";

export type User = PrismaUser;
  • Prisma で定義したモデルであれば、Prisma が生成した型をそのまま使用
  • ただし Prisma への依存を回避するためラッピングを行う

domain/interfaces

例: userRepositoryInterface.ts

import { User } from "@/domain/models/User";

export type IUserRepository = {
	create(data: { name: string, email: string }): Promise<User>;
    update(id: string, data: { name: string, email: string }): Promise<void>;
    findById(id: string): Promise<User | null>;
};
  • データを返す場合は domain/models で定義したモデルをそのまま返すのが絶対
  • 「あれ、参照系も入ってね?」と思うかもですが、コマンド側では「モデルをピュアなまま取り扱う」というのを主眼に置いているため、それが守られているのであれば参照系をリポジトリに入れるのは問題ないと思っています

repositories/

  • 例: userRepository.ts
import { prisma } from "@/lib/prisma";
import type { IUserRepository } from "@/domain/interfaces/userRepositoryInterface";
import type { User } from "@/domain/models/User";

export const userRepository = (tx?: TransactionContext): IUserRepository => {
  const client = tx ?? prisma;
  
  return {
    async create(data: { name: string; email: string }): Promise<User> {
      const user = await client.user.create({
        data: {
          name: data.name,
          email: data.email,
        },
      });
      return user;
    },

    async update(id: string, data: { name: string; email: string }): Promise<void> {
      await client.user.update({
        where: { id },
        data: {
          name: data.name,
          email: data.email,
        },
      });
    },

    async findById(id: string): Promise<User | null> {
      const user = await client.user.findUnique({
        where: { id },
      });
      return user;
    },
  };
};
  • ここでは selectinclude といったモデルを改変するメソッドは使用禁止

feature/types.ts

例: feature/posts/types.ts

export type PostWithAuthor = {
  id: string;
  title: string;
  excerpt: string | null;
  publishedAt: Date | null;
  // 著者情報をフラットに持つ
  author: {
    id: string;
    name: string;
    avatarUrl: string | null;
  };
};

export type PostQueries = {
  /** 投稿一覧を著者情報付きで取得 */
  getListWithAuthor(): Promise<PostWithAuthor[]>;
};
  • 画面要件に応じた柔軟なモデル, インターフェースを定義

feature/queries.ts

例: feature/posts/queries.ts

import { prisma } from "@/lib/prisma";
import type { PostQueries, PostWithAuthor } from "./interfaces";

export const postQueries: PostQueries = {
  async getListWithAuthor() {
    const posts = await prisma.post.findMany({
      where: { status: "published" },
      orderBy: { publishedAt: "desc" },
      select: {
        id: true,
        title: true,
        excerpt: true,
        publishedAt: true,
        // 著者情報をJOIN
        author: {
          select: {
            id: true,
            name: true,
            avatarUrl: true,
          },
        },
      },
    });

    return posts.map((post): PostWithAuthor => ({
      id: post.id,
      title: post.title,
      excerpt: post.excerpt,
      publishedAt: post.publishedAt,
      author: post.author,
    }));
  },
};
  • feature 側では types.ts に合わせて自由に selectinclude が使用可能

嬉しい点

  • コマンドとクエリを完全に分けることで、WithXxx といった画面要件依存の型とドメインモデルの混在を防ぐことができる
  • 参照系のメソッドそれぞれの feature 内で効率良くデータ取得を行うことができる
  • もし画面要件が突然変更になった場合でも、対象となる feature/ のコードを変更するだけで済む

改善点

  • Server Actions や キャッシュ周りのルール
  • PostToTag のような中間テーブルの扱い方
  • ルールを強制するための Linter の設定

終わりに

このようなアーキテクチャ系のことを考えているとどんどん沼ってしまってあっという間に時間が溶けてしまいます...(そこが面白い部分でもあります)

今回執筆したアイデアについてもまだまだ改善の余地が大いに残されているため引き続き研究していきたい所存です。

3
2
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
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?