Next.js 15とDDDで作る堅牢なシステム設計【第1部: 設計編】
はじめに
Next.js 15のApp RouterとServer Actionsが登場し、フロントエンド開発のパラダイムが大きく変わりました。しかし、新しい技術を使うだけでは、保守性の高いシステムは作れません。
本シリーズでは、ドメイン駆動設計(DDD) の思想をNext.js 15と組み合わせ、変更に強く、テストしやすい、プロフェッショナルなシステム設計を4部構成で解説します。
📚 シリーズ構成
-
【第1部: 設計編】 ← 今回
- DDDの基礎理解
- レイヤードアーキテクチャ設計
- ディレクトリ構成の決定
-
【第2部: Domain層編】
- エンティティと値オブジェクトの実装
- リポジトリパターン
- ドメインロジックのテスト
-
【第3部: Infrastructure & Application層編】
- Firebaseとの連携実装
- Server Actionsの活用
- バリデーションとエラーハンドリング
-
【第4部: Presentation層編】
- Server/Client Componentの使い分け
- フォーム実装とUI設計
- 実践的なパフォーマンス最適化
想定読者
- Next.js 13以降のApp Routerを使ったことがある方
- TypeScriptの基本的な知識がある方
- より保守性の高いコード設計に興味がある方
- DDDという言葉を聞いたことはあるが実践したことがない方
サンプルアプリケーション
本シリーズでは、クイズ管理システムを題材に実装していきます。
主な機能:
- クイズの作成・編集・削除
- クイズカテゴリのマスタ管理
- 質問と選択肢の管理
- 公開/非公開の制御
技術スタック:
- Next.js 15 (App Router)
- TypeScript
- Firebase (Firestore)
- shadcn/ui + Tailwind CSS
- Zod (バリデーション)
- React Hook Form
第1部: 設計編
なぜDDDなのか?
まず、多くのプロジェクトで見かける「貧血モデル」の問題点を見ていきましょう。
よくある実装(アンチパターン)
// ❌ 貧血モデル: データだけの型定義
type Quiz = {
id: string;
title: string;
isPublished: boolean;
questions: Question[];
};
// ビジネスロジックがコントローラーに散在
async function publishQuiz(id: string) {
const quiz = await db.collection('quizzes').doc(id).get();
// ビジネスルールがあちこちに
if (quiz.questions.length < 3) {
throw new Error('公開には最低3問必要です');
}
// 質問の検証ロジック
for (const q of quiz.questions) {
if (!q.text || q.choices.length < 2) {
throw new Error('不完全な質問があります');
}
}
await db.collection('quizzes').doc(id).update({ isPublished: true });
}
// 別の場所でも同じロジックが...
async function createQuiz(data: any) {
// また同じバリデーション
if (data.questions.length < 1) {
throw new Error('最低1問必要です');
}
// ...
}
この実装の問題点
-
ビジネスルールが散在
- 公開条件のチェックが複数の場所に
- メンテナンスが困難
-
重複したバリデーション
- 同じチェックロジックが各所に
- 変更時に漏れが発生しやすい
-
テストが困難
- データベースに依存
- モックが複雑になる
-
変更に弱い
- ビジネスルール変更時に複数箇所を修正
- 修正漏れのリスク
-
型安全性の欠如
-
any型の多用 - ランタイムエラーが発生しやすい
-
DDD的アプローチ
// ✅ リッチモデル: ビジネスロジックをエンティティに集約
class Quiz {
private constructor(private props: QuizProps) {
this.validate();
}
// ビジネスルール: 公開処理
publish(): Quiz {
// 公開条件の検証はこのメソッド内に集約
this.validatePublishConditions();
return new Quiz({
...this.props,
isPublished: true,
updatedAt: new Date(),
});
}
private validatePublishConditions(): void {
if (this.props.questions.length < 3) {
throw new Error('公開には最低3問必要です');
}
const allQuestionsValid = this.props.questions.every(q => q.isValid());
if (!allQuestionsValid) {
throw new Error('すべての質問を完成させてください');
}
}
}
// 使用側はシンプルに
const quiz = await repository.findById(id);
const publishedQuiz = quiz.publish(); // ビジネスルールはエンティティ内
await repository.save(publishedQuiz);
メリットの比較表
| 観点 | 貧血モデル | リッチモデル(DDD) |
|---|---|---|
| ビジネスルールの場所 | 各所に散在 | エンティティに集約 |
| テスタビリティ | DB依存で困難 | 独立してテスト可能 |
| 変更の影響範囲 | 広範囲に影響 | 局所的 |
| コードの可読性 | 低い | ビジネス用語で明確 |
| 保守性 | 低い | 高い |
DDDの核心的な考え方
DDDは単なるコーディングパターンではなく、ビジネスとコードを結びつける設計思想です。
重要な3つの概念
1. ユビキタス言語(Ubiquitous Language)
ビジネス側と開発者が同じ言葉でコミュニケーションする。
// ❌ 技術用語中心
class DataModel {
private data: Record<string, any>;
updateField(key: string, value: any) { ... }
}
// ✅ ビジネス用語中心
class Quiz {
publish() { ... } // 「公開する」
unpublish() { ... } // 「非公開にする」
changeTitle(newTitle: string) { ... } // 「タイトルを変更する」
}
コードを見れば、ビジネスルールが理解できる状態を目指します。
2. 境界づけられたコンテキスト(Bounded Context)
システムを意味のある単位で分割する。
クイズ管理システム
├── クイズコンテキスト
│ ├── Quiz(クイズ)
│ ├── Question(質問)
│ └── QuizRepository
│
└── カテゴリコンテキスト
├── Category(カテゴリ)
└── CategoryRepository
各コンテキストは独立して進化できます。
3. レイヤードアーキテクチャ
関心事を層で分離し、依存方向を制御する。
外側(技術的詳細)→ 内側(ビジネスロジック)への一方向依存
これが本記事の中心テーマです。
レイヤードアーキテクチャの設計
DDDでは、システムを4つの層に分離します。
全体像
┌─────────────────────────────────────┐
│ Presentation Layer │ ← UIとユーザー入力
│ ・Pages, Components │
│ ・フォーム処理 │
└──────────────┬──────────────────────┘
│ 呼び出し
┌──────────────▼──────────────────────┐
│ Application Layer │ ← ユースケース
│ ・Server Actions │
│ ・バリデーション │
│ ・トランザクション制御 │
└──────────────┬──────────────────────┘
│ 利用
┌──────────────▼──────────────────────┐
│ Domain Layer │ ← ビジネスロジック
│ ・エンティティ │
│ ・値オブジェクト │
│ ・リポジトリインターフェース │
└──────────────▲──────────────────────┘
│ 実装
┌──────────────┴──────────────────────┐
│ Infrastructure Layer │ ← 技術的詳細
│ ・データベースアクセス │
│ ・外部API連携 │
└─────────────────────────────────────┘
重要な原則: 依存性逆転の原則(DIP)
Domain層は他のどの層にも依存しません。
// Domain層: インターフェースのみ定義
interface QuizRepository {
save(quiz: Quiz): Promise<void>;
findById(id: string): Promise<Quiz | null>;
}
// Infrastructure層: 具体的な実装
class FirebaseQuizRepository implements QuizRepository {
async save(quiz: Quiz): Promise<void> {
// Firestore固有の処理
}
}
これにより:
- ビジネスロジックが技術的詳細から独立
- テストが容易(モックリポジトリ使用可能)
- データベースの変更(Firebase→PostgreSQL)が容易
各層の責務
Presentation Layer(プレゼンテーション層)
責務:
- ユーザーインターフェースの表示
- ユーザー入力の受け取り
- Application層への委譲
含まれるもの:
- Next.jsのページコンポーネント
- UIコンポーネント
- フォーム
やってはいけないこと:
- ビジネスロジックを書く
- 直接データベースにアクセス
Application Layer(アプリケーション層)
責務:
- ユースケースの実装
- トランザクション境界の管理
- ドメインオブジェクトの組み立て
含まれるもの:
- Server Actions
- バリデーションスキーマ(Zod)
- DTO(Data Transfer Object)
やってはいけないこと:
- ビジネスルールを書く(Domain層の役割)
- UI表示ロジックを書く
Domain Layer(ドメイン層)
責務:
- ビジネスルールの実装
- ドメイン知識の表現
- 不変条件の保護
含まれるもの:
- エンティティ
- 値オブジェクト
- リポジトリインターフェース
- ドメインサービス
特徴:
- 純粋なTypeScript(フレームワーク依存なし)
- 外部ライブラリを使わない
- 最も重要な層
やってはいけないこと:
- データベースアクセスコードを書く
- フレームワーク固有のコードを書く
Infrastructure Layer(インフラストラクチャ層)
責務:
- 外部サービスとの連携
- データの永続化
- 変換ロジック(ドメインモデル⇔永続化形式)
含まれるもの:
- リポジトリの実装
- Firebase/Prisma等のDB接続
- 外部API呼び出し
やってはいけないこと:
- ビジネスロジックを書く
ディレクトリ構成の設計
レイヤードアーキテクチャを反映したディレクトリ構成を設計します。
推奨ディレクトリ構成
src/
├── app/ # Presentation Layer
│ ├── layout.tsx
│ ├── page.tsx
│ ├── quizzes/
│ │ ├── page.tsx # クイズ一覧
│ │ ├── new/
│ │ │ └── page.tsx # クイズ作成
│ │ └── [id]/
│ │ └── edit/
│ │ └── page.tsx # クイズ編集
│ └── categories/
│ ├── page.tsx
│ └── new/
│ └── page.tsx
│
├── features/ # Application Layer
│ ├── quiz/
│ │ ├── actions/ # Server Actions
│ │ │ ├── create-quiz.ts
│ │ │ ├── update-quiz.ts
│ │ │ ├── delete-quiz.ts
│ │ │ ├── publish-quiz.ts
│ │ │ └── get-quizzes.ts
│ │ ├── components/ # Feature固有のUI
│ │ │ ├── quiz-form.tsx
│ │ │ ├── quiz-list.tsx
│ │ │ └── quiz-card.tsx
│ │ └── schemas/ # Zodバリデーション
│ │ └── quiz-schema.ts
│ │
│ └── category/
│ ├── actions/
│ │ ├── create-category.ts
│ │ ├── update-category.ts
│ │ └── get-categories.ts
│ ├── components/
│ │ ├── category-form.tsx
│ │ └── category-list.tsx
│ └── schemas/
│ └── category-schema.ts
│
├── domain/ # Domain Layer
│ ├── quiz/
│ │ ├── entities/
│ │ │ ├── quiz.ts # クイズエンティティ
│ │ │ └── question.ts # 質問値オブジェクト
│ │ ├── repositories/
│ │ │ └── quiz-repository.ts # インターフェース
│ │ └── services/
│ │ └── quiz-validator.ts # ドメインサービス
│ │
│ └── category/
│ ├── entities/
│ │ └── category.ts
│ └── repositories/
│ └── category-repository.ts
│
├── infrastructure/ # Infrastructure Layer
│ ├── firebase/
│ │ ├── config.ts # Firebase設定
│ │ ├── converters.ts
│ │ └── repositories/
│ │ ├── firebase-quiz-repository.ts
│ │ └── firebase-category-repository.ts
│ └── errors/
│ └── app-error.ts
│
└── components/ # 共通UIコンポーネント
└── ui/ # shadcn/ui
├── button.tsx
├── card.tsx
├── form.tsx
└── ...
設計のポイント
1. 層ごとにディレクトリを分離
src/
├── app/ # Presentation
├── features/ # Application
├── domain/ # Domain
└── infrastructure/ # Infrastructure
各層の責務が明確になり、コードの配置場所に迷いません。
2. ドメイン駆動の構成
domain/
├── quiz/ # クイズコンテキスト
└── category/ # カテゴリコンテキスト
ビジネスドメインごとにディレクトリを分け、境界を明確にします。
3. 技術的な関心事を分離
features/quiz/
├── actions/ # Server Actions(Next.js固有)
├── components/ # UIコンポーネント(React固有)
└── schemas/ # バリデーション(Zod固有)
技術的な詳細は外側の層に配置します。
Next.js 15との統合ポイント
Next.js 15の機能とDDDの統合方法を解説します。
App Routerとの相性
app/ ← Next.jsの規約(Presentation)
features/ ← カスタム(Application)
domain/ ← カスタム(Domain)
infrastructure/ ← カスタム(Infrastructure)
app/ディレクトリはNext.jsの規約に従い、ビジネスロジックはdomain/に配置することで、両立できます。
Server Actions の位置づけ
// features/quiz/actions/create-quiz.ts
'use server'; // Next.js 15のServer Actions
export async function createQuiz(data: unknown) {
// Application層の責務
// 1. バリデーション
// 2. ドメインオブジェクトの組み立て
// 3. リポジトリへの永続化依頼
}
Server ActionsはApplication層に配置します。
Server/Client Componentの使い分け
| Component種類 | 用途 | 配置場所 | 例 |
|---|---|---|---|
| Server Component | データフェッチ、静的表示 | app/ |
ページコンポーネント |
| Client Component | インタラクション | features/*/components/ |
フォーム |
// Server Component(app/quizzes/page.tsx)
export default async function QuizzesPage() {
const quizzes = await getQuizzes(); // サーバー側でフェッチ
return <QuizList quizzes={quizzes} />;
}
// Client Component(features/quiz/components/quiz-form.tsx)
'use client';
export default function QuizForm() {
const [state, setState] = useState(); // クライアント側の状態
// ...
}
設計判断のガイドライン
DDDを採用すべきケース
✅ 推奨する場合:
- 複雑なビジネスルールがある
- 例: 承認フロー、料金計算、在庫管理
- 長期的な保守が必要(3年以上)
- チームで開発する(5人以上)
- ドメインエキスパートと協業する
- 技術スタックが変わる可能性がある
❌ 過剰な場合:
- シンプルなCRUD操作のみ
- プロトタイプや短期プロジェクト(3ヶ月以内)
- 個人開発や小規模チーム(2-3人)
- 明確なビジネスルールがない
段階的な導入アプローチ
いきなり完璧なDDDを目指す必要はありません。
フェーズ1: ドメイン層だけ導入
↓
フェーズ2: リポジトリパターンを追加
↓
フェーズ3: 完全なレイヤードアーキテクチャへ
フェーズ1の例:
// まずはエンティティだけ作る
class Quiz {
publish() {
// ビジネスルールを集約
}
}
// リポジトリはまだ普通の関数
async function saveQuiz(quiz: Quiz) {
await db.collection('quizzes').doc(quiz.id).set(quiz.toObject());
}
小さく始めて、徐々に拡大していくのがおすすめです。
実装前のチェックリスト
実装を始める前に、以下を確認しましょう。
アーキテクチャ設計
- レイヤーの責務を理解している
- 依存方向(外→内)を理解している
- ディレクトリ構成を決定した
ドメイン理解
- ユビキタス言語を定義した
- エンティティを特定した
- 値オブジェクトを特定した
- ビジネスルールを洗い出した
技術スタック
- Next.js 15をセットアップした
- TypeScriptを設定した
- FirebaseまたはDBを用意した
- shadcn/uiをインストールした
まとめ
第1部では、DDDの基礎とアーキテクチャ設計について解説しました。
重要なポイント
-
DDDはビジネスとコードを結びつける思想
- 単なるコーディングパターンではない
- ユビキタス言語で共通理解を作る
-
レイヤードアーキテクチャで関心事を分離
- 4つの層(Presentation, Application, Domain, Infrastructure)
- 依存方向は常に内側(Domain)に向かう
-
Domain層が最も重要
- ビジネスロジックを集約
- 技術的詳細から独立
- テストしやすい
-
Next.js 15との統合
-
app/ディレクトリとの共存 - Server ActionsはApplication層
- Server/Client Componentの適切な使い分け
-
次回予告
【第2部: Domain層編】では、実際にエンティティと値オブジェクトを実装していきます。
- クイズエンティティの完全実装
- 質問値オブジェクトの設計
- リポジトリインターフェースの定義
- ドメインロジックのユニットテスト
具体的なコードを書きながら、DDDの設計思想を体感していきましょう!
参考資料
この記事が役に立ったら、いいねやコメントをいただけると嬉しいです!
質問やフィードバックもお待ちしています。
次回第2部: Domain層編もお楽しみに!
Discussion