はじめに
Next.js(App Router)× Prisma の構成は近年のWebアプリ開発ではよく見る構成かと思います。
ただプロジェクトが育ってくると、とある問題にぶつかります。
- 画面ごとに最適なデータを取得するために
includeやselectが増える - すると
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 の
selectやincludeでモデルを改変することは禁止 -
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;
},
};
};
- ここでは
selectやincludeといったモデルを改変するメソッドは使用禁止
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に合わせて自由にselectやincludeが使用可能
嬉しい点
- コマンドとクエリを完全に分けることで、
WithXxxといった画面要件依存の型とドメインモデルの混在を防ぐことができる - 参照系のメソッドそれぞれの feature 内で効率良くデータ取得を行うことができる
- もし画面要件が突然変更になった場合でも、対象となる
feature/のコードを変更するだけで済む
改善点
- Server Actions や キャッシュ周りのルール
-
PostToTagのような中間テーブルの扱い方 - ルールを強制するための Linter の設定
終わりに
このようなアーキテクチャ系のことを考えているとどんどん沼ってしまってあっという間に時間が溶けてしまいます...(そこが面白い部分でもあります)
今回執筆したアイデアについてもまだまだ改善の余地が大いに残されているため引き続き研究していきたい所存です。