🏗️

Next.js 15とDDDで作る堅牢なシステム設計【第1部: 設計編】

に公開

はじめに

Next.js 15のApp RouterとServer Actionsが登場し、フロントエンド開発のパラダイムが大きく変わりました。しかし、新しい技術を使うだけでは、保守性の高いシステムは作れません。

本シリーズでは、ドメイン駆動設計(DDD) の思想をNext.js 15と組み合わせ、変更に強く、テストしやすい、プロフェッショナルなシステム設計を4部構成で解説します。

📚 シリーズ構成

  1. 【第1部: 設計編】 ← 今回

    • DDDの基礎理解
    • レイヤードアーキテクチャ設計
    • ディレクトリ構成の決定
  2. 【第2部: Domain層編】

    • エンティティと値オブジェクトの実装
    • リポジトリパターン
    • ドメインロジックのテスト
  3. 【第3部: Infrastructure & Application層編】

    • Firebaseとの連携実装
    • Server Actionsの活用
    • バリデーションとエラーハンドリング
  4. 【第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問必要です');
  }
  // ...
}

この実装の問題点

  1. ビジネスルールが散在

    • 公開条件のチェックが複数の場所に
    • メンテナンスが困難
  2. 重複したバリデーション

    • 同じチェックロジックが各所に
    • 変更時に漏れが発生しやすい
  3. テストが困難

    • データベースに依存
    • モックが複雑になる
  4. 変更に弱い

    • ビジネスルール変更時に複数箇所を修正
    • 修正漏れのリスク
  5. 型安全性の欠如

    • 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の基礎とアーキテクチャ設計について解説しました。

重要なポイント

  1. DDDはビジネスとコードを結びつける思想

    • 単なるコーディングパターンではない
    • ユビキタス言語で共通理解を作る
  2. レイヤードアーキテクチャで関心事を分離

    • 4つの層(Presentation, Application, Domain, Infrastructure)
    • 依存方向は常に内側(Domain)に向かう
  3. Domain層が最も重要

    • ビジネスロジックを集約
    • 技術的詳細から独立
    • テストしやすい
  4. Next.js 15との統合

    • app/ディレクトリとの共存
    • Server ActionsはApplication層
    • Server/Client Componentの適切な使い分け

次回予告

【第2部: Domain層編】では、実際にエンティティと値オブジェクトを実装していきます。

  • クイズエンティティの完全実装
  • 質問値オブジェクトの設計
  • リポジトリインターフェースの定義
  • ドメインロジックのユニットテスト

具体的なコードを書きながら、DDDの設計思想を体感していきましょう!


参考資料

この記事が役に立ったら、いいねやコメントをいただけると嬉しいです!
質問やフィードバックもお待ちしています。

次回第2部: Domain層編もお楽しみに!

Discussion