Next.js (TypeScript) で学ぶ クリーンアーキテクチャ・DDD 実践ガイド

このガイドは、クリーンアーキテクチャをはじめて学ぶ人が、Next.js(App Router)で「なぜそう書くのか」を理解しながら実装できるようになることを目的としています。用語の説明からはじめ、最後に動く実例(ユーザー登録機能)まで通しで作ります。


0. はじめに:このガイドの読み方

クリーンアーキテクチャは「正解の形」を暗記するものではありません。たった1つのルールを守るための、いくつかの道具立てです。そのルールとは次の一文に尽きます。

依存の矢印は、外側から内側へ。内側は外側を知らない。

このガイドでは、まずこの一文の意味を噛み砕き(1〜3章)、次に各層が何を担当するかを整理し(4章)、最後に実例コードで全体を組み立てます(5〜7章)。はじめての方は順番に、経験者は4章と7章だけ拾い読みしても構いません。


1. 用語をはじめに揃える

聞き慣れない言葉が多いので、最初にまとめて意味を確認します。あとで全部出てきます。

用語 ざっくりした意味
ドメイン(Domain) アプリが扱う「業務そのもの」。ECなら「注文」「在庫」、SNSなら「投稿」「フォロー」。
エンティティ(Entity) IDで区別される業務上の「もの」。同じ名前でもIDが違えば別物(例:ユーザー)。
値オブジェクト(Value Object, VO) 値そのものに意味がある不変の型。IDを持たず、中身が同じなら同じ(例:メールアドレス、金額)。
ユースケース(Use Case) ユーザーがやりたいこと1つ分の流れ(例:「ユーザーを登録する」)。
リポジトリ(Repository) データの保存・取得の「窓口」。DBの細かい都合を隠す。
DTO(Data Transfer Object) 層をまたいでデータを運ぶためだけの、ロジックを持たない箱。
インターフェース(interface) 「何ができるか」だけを定めた約束。中身(どうやるか)は書かない。
依存性逆転(DIP) 上位が下位の「実体」ではなく「約束(interface)」に依存するようにする考え方。
DI(依存性注入) 必要な部品を外から渡してもらうこと。new を内部でせず、引数で受け取る。

この表は読み飛ばしても大丈夫です。本文中で都度説明します。


2. なぜ「層」に分けるのか — 玉ねぎの比喩

クリーンアーキテクチャでは、コードを同心円(玉ねぎ)状の層に分けます。中心に近いほど「変わりにくい本質」、外側ほど「変わりやすい技術の都合」です。

        ┌─────────────────────────────────────────┐
        │  外側:フレームワーク・DB・外部API          │   ← よく変わる(Next.js, Prisma…)
        │   ┌───────────────────────────────────┐   │
        │   │  プレゼンテーション(UI・HTTP)        │   │
        │   │   ┌─────────────────────────────┐   │   │
        │   │   │  ユースケース(機能の流れ)      │   │   │
        │   │   │   ┌───────────────────────┐   │   │   │
        │   │   │   │  ドメイン(業務ルール)   │   │   │   │   ← めったに変わらない(業務の本質)
        │   │   │   └───────────────────────┘   │   │   │
        │   │   └─────────────────────────────┘   │   │
        │   └───────────────────────────────────┘   │
        └─────────────────────────────────────────┘

           依存の矢印は つねに 外 → 内
           (内側のコードは、外側の存在を一切知らない)

なぜこの形が嬉しいのか。理由は1つです。

「変わりやすいもの」を「変わりにくいもの」に依存させると、変更が中心に伝染しない。

たとえば DB を Prisma から別のものに乗り換えるとき。業務ルール(ドメイン)が Prisma を知らなければ、ドメインは1行も直さずに済みます。逆に、ドメインの中で Prisma を直接呼んでいたら、DB 乗り換えのたびに業務ルールごと壊れます。


3. 唯一にして最大のルール:依存の向き

3.1 「依存する」とは何か

コードAが「コードBを import している」「Bの型を使っている」「Bを new している」とき、AはBに依存していると言います。BがいないとAはコンパイルも実行もできないからです。

クリーンアーキテクチャの掟は「内側の層は、外側の層を import してはいけない」というだけです。ドメインのファイルが next/server@prisma/client を import していたら、その時点でルール違反です。

3.2 でも、内側は外側の力を借りたい — 「依存性逆転」の出番

ここで素朴な疑問が出ます。「ユースケース(内側)は、DB保存(外側)をしたい。なのに外側を import できないなら、どうやって保存するの?」

答えが**依存性逆転(Dependency Inversion)**です。発想を逆にします。

  1. 内側が「保存する人はこの約束を満たしてね」という interface(約束)を定義する
  2. 外側が、その約束を満たす 実装(class)を用意する
  3. 内側は「約束」だけを見て動く。実体が誰かは知らない。

コンセントで例えるとわかりやすいです。あなたの掃除機(内側)は「コンセントの形(interface)」さえ合えば動きます。背後の発電所が火力か原子力か太陽光か(外側の実装)を、掃除機は知る必要がありません。

【ふつうの依存(NG)】              【依存性逆転(OK)】

 ユースケース                       ユースケース
     │ import                          │ 依存するのは…
     ▼                                 ▼
 DBの実装クラス                    リポジトリの interface(約束)  ← これはドメイン側に置く
 (Prisma直書き)                       ▲
                                        │ implements(約束を満たす)
                                    DBの実装クラス(Prisma)       ← 外側

 内側が外側を見ている = 汚染        外側が内側の約束を見ている = 健全

ポイントは、interface(約束)を内側(ドメイン)に置くことです。これで矢印の向きが「外→内」に保たれます。「実装は外、約束は内」と覚えてください。

3.3 約束と実体をつなぐのは誰か — Composition Root

interface(約束)と class(実体)を最終的に結びつける作業(「この約束にはこの実装を使う」と new する場所)が必要です。これを**コンポジションルート(Composition Root)**と呼び、アプリ内のたった1か所に集約します。

なぜ1か所か。もし画面やAPIのあちこちで new PrismaUserRepository() と書いていたら、DB乗り換え時に全箇所を書き換えることになります。結線を1か所に閉じ込めておけば、乗り換えはそのファイルだけで済みます。「具体的な実装クラスを new していいのは Composition Root だけ」——これが鉄則です。


4. 各層の責務 — 「どこに何を書くか」の早見表

ここが実務でいちばん参照する部分です。迷ったらこの表に戻ってください。

4.1 ドメイン層(最内部)

4.2 ユースケース層(内側)

4.3 プレゼンテーション層(外側)

4.4 インフラストラクチャ層(最外周)

4.5 一目でわかる依存方向

  app/(Next.jsの入口)
        │ 呼ぶ
        ▼
  presentation/  ──────┐
        │ 呼ぶ           │ どちらも
        ▼                │ 依存するのは
  usecases/  ───────────┤ ドメインの「interface」
        │ 依存            │
        ▼                │
  domain/(interface)◄──┘
        ▲
        │ implements(約束を満たす)
  infrastructure/(実装クラス)

  ※ infrastructure は domain の「約束」を実装するが、
    domain は infrastructure を一切 import しない。
    両者を new で結ぶのは Composition Root(infrastructure/di)だけ。

5. フォルダ構成 — 実例(ユーザー登録)で配置する

ここから具体例に入ります。題材は「ユーザーを登録する」機能です。一覧表示(読み取り)と登録(書き込み)の両方を、最小構成で通しで作ります。

src/
├── domain/                          # 【ドメイン層】業務ルール。外部を一切 import しない
│   ├── models/
│   │   ├── UserEntity.ts            #  [Entity] IDで識別される「ユーザー」。業務ロジックを持つ
│   │   └── EmailVO.ts               #  [Value Object] 正しい形式しか存在できないメールアドレス型
│   └── repositories/
│       └── UserRepository.ts        #  [interface] 「保存する/存在確認する」という約束
│
├── usecases/                        # 【ユースケース層】機能の流れを統治する司令塔
│   ├── dto/
│   │   ├── UserInputDTO.ts           #  入力の運び箱(外→ユースケース)
│   │   └── UserOutputDTO.ts          #  出力の運び箱(ユースケース→外)。境界を越えるので日時はISO文字列
│   ├── queries/
│   │   └── UserQueryService.ts        #  [interface] 一覧表示など「読むだけ」の約束(後述のCQRS)
│   └── RegisterUserUseCase.ts        #  登録の流れ:検証→保存→結果整形
│
├── presentation/                    # 【プレゼンテーション層】入力検証・例外翻訳・表示整形
│   ├── shared/
│   │   ├── userValidation.ts         #  入口共通の入力検証(純粋関数)
│   │   ├── errorMapping.ts            #  ドメイン例外→ユーザー向けエラーへの翻訳(純粋関数)
│   │   └── formatters.ts              #  日付の表示整形などUI都合の処理
│   └── controllers/
│       └── UserController.ts          #  Route Handler 用の窓口。HTTPレスポンスへ変換
│
├── infrastructure/                  # 【インフラ層】約束を具体技術で実装する
│   ├── prisma/
│   │   └── schema.prisma              #  DBスキーマ(password_hash 等、画面に出さない列もここ)
│   ├── queries/
│   │   └── UserQueryServiceImpl.ts     #  読み取り専用:SQLから直接 DTO を組み立てる
│   ├── repositories/
│   │   └── UserRepositoryImpl.ts       #  UserRepository(約束) の DB 実装
│   └── di/
│       └── container.ts               #  【Composition Root】具象を new して結線する唯一の場所
│
└── app/                             # 【Next.js ルーティング】薄い入口(ロジックは持たせない)
    ├── users/
    │   ├── page.tsx                   #  一覧表示(読み取り)+登録フォームの設置
    │   ├── RegisterForm.tsx            #  登録フォーム(Client Component。状態表示を担当)
    │   └── actions.ts                  #  Server Action(フォーム送信の受け皿。検証→実行→再検証)
    └── api/
        └── v1/
            └── users/
                └── route.ts            #  APIエンドポイント。Controller に丸投げ

補足:app/ のラベルは「薄い入口」ですが、actions.ts は実際には検証・実行・キャッシュ再検証を行う プレゼンテーション層の入口です。物理的には Next.js の規約上 app/ に置く必要がありますが、役割としては presentation の一員だと理解してください。「フォルダの場所」と「論理的な層」は必ずしも一致しないことの好例です。


6. なぜ「DBの形」と「ドメインの形」を分けるのか

初学者がいちばん「面倒では?」と感じるのが、DBスキーマとドメインモデルを別々に定義し、インフラ層で「詰め替え」をする点です。「DBのカラムをそのまま使えばいいのでは?」という疑問に、3つの理由で答えます。

理由1:機密情報の流出を構造的に防ぐ(セキュリティの壁) DBの users テーブルには password_hash のような、画面に絶対出したくない列があります。ドメインの UserEntity にこの列を持たせなければ、そもそも画面に渡しようがありません。「出さない」ではなく「持っていないから出せない」状態を作るのが安全です。

理由2:業務にとって扱いやすい形を定義できる DBでは first_namelast_name に分かれていても、業務では fullName で扱いたいことがあります。こうした「業務にとって自然な表現」をドメイン側のロジック(例:get fullName())に集約できます。

理由3:外部ライブラリの変更から内側を守る(防波堤) 画面で Prisma の自動生成型を直接使うと、DB乗り換えやマイグレーションのたびに画面がエラーだらけになります。純粋な TypeScript の型である UserEntity に一度「詰め替え」ておけば、外側の変更はインフラ層で食い止められます。


7. 実装 — 全層を通しで組み立てる

ここからは実際に動かせるコードです。各ブロックの先頭に「どの層か」と「なぜそう書くか」を添えます。

7.1 ドメイン層:エンティティ・値オブジェクト・約束

値オブジェクト(VO)は「不正な値では存在できない」のが肝です。EmailVO を作れた時点で、その中身は必ず妥当だと保証されます。

// src/domain/models/EmailVO.ts
// [値オブジェクト] 不正な形式ではインスタンス化できない = 存在=妥当の保証
export class EmailVO {
  constructor(public readonly value: string) {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(value)) {
      throw new Error('INVALID_EMAIL_FORMAT');
    }
  }
}

エンティティは「業務上の振る舞い」を持ちます。デフォルトロールの付与(=業務ルール)や fullName の組み立てをここに置くのがポイントです。

// src/domain/models/UserEntity.ts
// [エンティティ] IDで識別される業務上の「ユーザー」。業務ロジックを内包する
import { EmailVO } from './EmailVO';

export type UserRole = 'admin' | 'member';

export class UserEntity {
  constructor(
    public readonly id: string,
    public readonly firstName: string,
    public readonly lastName: string,
    public readonly email: EmailVO,
    public readonly role: UserRole,
    public readonly createdAt: Date | null, // 保存前は null、保存後は必ず値が入る
  ) {}

  // 「新規ユーザーは member」という業務ルールはインフラではなくドメインで決める
  static createNew(
    id: string,
    firstName: string,
    lastName: string,
    email: EmailVO,
  ): UserEntity {
    return new UserEntity(id, firstName, lastName, email, 'member', null);
  }

  // DB保存後に、DBが確定させた日時を持つ新インスタンスを返す(不変を保ったまま更新)
  withCreatedAt(createdAt: Date): UserEntity {
    return new UserEntity(
      this.id, this.firstName, this.lastName, this.email, this.role, createdAt,
    );
  }

  // 業務にとって自然な表現をドメインに集約
  get fullName(): string {
    return `${this.lastName} ${this.firstName}`;
  }
}

リポジトリの interface(約束)はドメインに置きます。これが依存性逆転の核です。

// src/domain/repositories/UserRepository.ts
// [interface] 「保存・存在確認」という約束。実体(Prisma等)は知らない
import { UserEntity } from '../models/UserEntity';

export interface UserRepository {
  // 保存後は createdAt が確定した Entity を返す契約(日時を正しく外へ運ぶため)
  save(user: UserEntity): Promise<UserEntity>;
  existsByEmail(email: string): Promise<boolean>;
}

7.2 ユースケース層:DTOと司令塔

DTOは「層をまたぐ運び箱」です。出力DTOの日時を Date ではなく ISO 文字列にしている点に注目してください。HTTPやServer Actionの境界を越えると Date は自動で文字列化され、型と実態がずれるためです。最初から文字列で定義すれば嘘がなくなります。

// src/usecases/dto/UserInputDTO.ts
export interface UserInputDTO {
  firstName: string;
  lastName: string;
  email: string;
}

// src/usecases/dto/UserOutputDTO.ts
export interface UserOutputDTO {
  id: string;
  fullName: string;
  email: string;
  registeredAtIso: string; // 境界を越えるので最初から ISO 文字列
}

// src/usecases/queries/UserQueryService.ts
// 一覧表示など「読むだけ」の処理は、エンティティを介さず専用DTOを直接取る(後述のCQRS)
export interface UserOverviewDTO {
  id: string;
  fullName: string;
  email: string;
}
export interface UserQueryService {
  fetchOverviewList(): Promise<UserOverviewDTO[]>;
}

司令塔(ユースケース)は interface だけに依存し、実体はコンストラクタで注入してもらいます。流れは「検証 → 保存 → 結果整形」とシンプルです。

// src/usecases/RegisterUserUseCase.ts
// [ユースケース] 検証→保存→結果整形 という流れを統治する
import { UserRepository } from '../domain/repositories/UserRepository';
import { UserEntity } from '../domain/models/UserEntity';
import { EmailVO } from '../domain/models/EmailVO';
import { UserInputDTO } from './dto/UserInputDTO';
import { UserOutputDTO } from './dto/UserOutputDTO';

export class RegisterUserUseCase {
  // 実装クラスではなく interface を注入してもらう(だからテストでモックに差し替え可能)
  constructor(private userRepository: UserRepository) {}

  async execute(input: UserInputDTO): Promise<UserOutputDTO> {
    // EmailVO を作る時点で形式が保証される(不正なら例外)
    const emailVO = new EmailVO(input.email);

    // 早期チェック。ただし並行リクエストではすり抜ける。最終防壁は DB の UNIQUE 制約(後述)
    const isTaken = await this.userRepository.existsByEmail(emailVO.value);
    if (isTaken) throw new Error('EMAIL_ALREADY_EXISTS');

    // 業務ルールを適用して Entity を組み立てる
    const newUser = UserEntity.createNew(
      crypto.randomUUID(), input.firstName, input.lastName, emailVO,
    );

    // 保存。戻り値は createdAt が確定した Entity(契約)
    const savedUser = await this.userRepository.save(newUser);

    // save の契約上 createdAt は非 null。型の保険として明示チェック
    if (!savedUser.createdAt) throw new Error('INTERNAL_SERVER_ERROR');

    return {
      id: savedUser.id,
      fullName: savedUser.fullName,
      email: savedUser.email.value,
      registeredAtIso: savedUser.createdAt.toISOString(),
    };
  }
}

7.3 インフラ層:実装と Composition Root

リポジトリ実装は 保存に徹します(単一責任)。また、DB固有のエラー(UNIQUE制約違反など)を ドメインの言葉に翻訳して投げ直すのもインフラの仕事です。これで上の層はDB都合を知らずにエラーを扱えます。

// src/infrastructure/repositories/UserRepositoryImpl.ts
// [実装] UserRepository(約束) を Prisma 等で実現する。保存に専念する
import { UserRepository } from '../../domain/repositories/UserRepository';
import { UserEntity } from '../../domain/models/UserEntity';

export class UserRepositoryImpl implements UserRepository {
  async save(user: UserEntity): Promise<UserEntity> {
    try {
      // 実際は prisma.user.create({ data: { ... } }) で保存し、戻り値の createdAt を使う
      const dbCreatedAt = new Date(); // ここでは DB 生成日時のモック
      console.log(`[DB Saved] ${user.id}`);
      return user.withCreatedAt(dbCreatedAt);
    } catch (error: any) {
      // インフラ例外をドメイン例外へ翻訳(例:Prisma の UNIQUE 違反 P2002)
      // if (error.code === 'P2002') throw new Error('EMAIL_ALREADY_EXISTS');
      throw error;
    }
  }

  async existsByEmail(email: string): Promise<boolean> {
    return false; // デモ用。実際は SELECT で存在確認
  }
}

読み取り専用の一覧取得は、エンティティを経由せず SQLから直接 DTO を組み立てるのが効率的です(次節のCQRS)。

// src/infrastructure/queries/UserQueryServiceImpl.ts
import { UserQueryService, UserOverviewDTO } from '../../usecases/queries/UserQueryService';

export class UserQueryServiceImpl implements UserQueryService {
  async fetchOverviewList(): Promise<UserOverviewDTO[]> {
    // 実際は SELECT id, ... で画面用の形を直接取得する
    return [{ id: '1', fullName: '山田 太郎', email: 'yamada@example.com' }];
  }
}

そして 唯一の結線場所、Composition Root。具象クラスを new していいのはここだけです。

// src/infrastructure/di/container.ts
// 【Composition Root】約束(interface)と実体(class)を結びつける唯一の場所
import { RegisterUserUseCase } from '@/usecases/RegisterUserUseCase';
import { UserQueryServiceImpl } from '../queries/UserQueryServiceImpl';
import { UserRepositoryImpl } from '../repositories/UserRepositoryImpl';

export const makeRegisterUserUseCase = () =>
  new RegisterUserUseCase(new UserRepositoryImpl());

export const makeUserQueryService = () => new UserQueryServiceImpl();

注:ここでは毎回 new する単純な Factory です。Prisma クライアントのように接続を持つ実装に育ったら、リクエスト単位の生成やシングルトン化など、生成スコープの方針が別途必要になります。

7.4 プレゼンテーション層:検証・翻訳・窓口

ここが初学者の落とし穴です。入口(API用のController と 画面用のServer Action)は2つありますが、検証と例外翻訳は両方で同じものを使い回したい。そこで、フレームワークに依存しない 純粋関数として切り出します。

まず例外クラス。素のオブジェクトを throw せず、Error を継承してスタックトレースと型判定を効かせます。

// src/presentation/shared/errorMapping.ts
export class PresentationError extends Error {
  constructor(
    public readonly status: number,
    public readonly code: string,
    message: string,
    public readonly details?: unknown, // any を避ける
  ) {
    super(message);
    this.name = 'PresentationError';
  }
}

// ドメイン例外 → ユーザー向けエラーへの翻訳(純粋関数。HTTPにもUIにも依存しない)
export function toPresentationError(error: unknown): PresentationError {
  if (error instanceof PresentationError) return error;
  const message = error instanceof Error ? error.message : String(error);

  if (message === 'EMAIL_ALREADY_EXISTS') {
    return new PresentationError(409, 'CONFLICT', 'このメールアドレスは既に登録されています');
  }
  if (message === 'INVALID_EMAIL_FORMAT') {
    // 通常は境界の Zod で弾かれ到達しない。万一漏れても 500 にせず 400 に落とす保険
    return new PresentationError(400, 'BAD_REQUEST', 'メールアドレスの形式が不正です');
  }
  // 予期せぬ内部エラーは詳細を隠す
  return new PresentationError(500, 'INTERNAL_SERVER_ERROR', 'サーバー内部でエラーが発生しました');
}

入力検証も純粋関数に。Zod が第一の防御、ドメインのVOが最後の砦、という二段構えです。

// src/presentation/shared/userValidation.ts
import { z } from 'zod';
import { UserInputDTO } from '@/usecases/dto/UserInputDTO';
import { PresentationError } from './errorMapping';

const registerSchema = z.object({
  firstName: z.string().min(1, 'FIRST_NAME_REQUIRED'),
  lastName: z.string().min(1, 'LAST_NAME_REQUIRED'),
  email: z.string().email('INVALID_EMAIL_FORMAT'),
});

export function parseRegisterInput(rawInput: unknown): UserInputDTO {
  const parsed = registerSchema.safeParse(rawInput);
  if (!parsed.success) {
    throw new PresentationError(400, 'BAD_REQUEST', '入力内容に不備があります', parsed.error.format());
  }
  return parsed.data;
}
// src/presentation/shared/formatters.ts
// 表示整形はユースケースではなくプレゼンテーションの責務
export const formatJapaneseDate = (isoString: string): string =>
  new Date(isoString).toLocaleDateString('ja-JP');

API用のControllerは「検証 → 結線・実行 → 翻訳」の順に、純粋関数を組み立てるだけ。ユースケースの取得・実行(結線)はControllerが自分で行うのがポイントです(共有関数に実行まで押し込めると、共有関数が層を縦断してしまうため)。

// src/presentation/controllers/UserController.ts
import { NextResponse } from 'next/server';
import { makeRegisterUserUseCase } from '@/infrastructure/di/container';
import { parseRegisterInput } from '../shared/userValidation';
import { toPresentationError } from '../shared/errorMapping';

export class UserController {
  async register(request: Request): Promise<Response> {
    try {
      const body = await request.json();
      const inputDto = parseRegisterInput(body);              // 1. 検証(共有)
      const usecase = makeRegisterUserUseCase();               // 2. 結線(Controllerの責務)
      const outputDto = await usecase.execute(inputDto);       //    実行
      return NextResponse.json(outputDto, { status: 201 });
    } catch (error) {
      const e = toPresentationError(error);                    // 3. 翻訳(共有)
      return NextResponse.json(
        { code: e.code, message: e.message, details: e.details },
        { status: e.status },
      );
    }
  }
}

7.5 Next.js への接続:読み取りと書き込みを分ける(CQRS入門)

最後に Next.js の入口です。ここで CQRS という考え方が出ます。難しく聞こえますが、要は「読む処理(Query)と書く処理(Command)を分ける」だけです。

なぜ分けるか。画面の表示(GET相当)は何度開いても同じであるべき(冪等)です。もし表示のたびに登録処理が走ったら大惨事です。だから書き込みは必ず明示的なアクションに閉じ込めます。

まず書き込み側の Server Action。Controllerと同じ純粋関数を通すので、API経由でも画面経由でも検証・エラー品質が揃います。書き込み後は revalidatePath で読み取り側のキャッシュを更新するのを忘れずに(忘れると「登録したのに一覧に出ない」バグになります)。

// src/app/users/actions.ts
'use server';

import { makeRegisterUserUseCase } from '@/infrastructure/di/container';
import { parseRegisterInput } from '@/presentation/shared/userValidation';
import { toPresentationError } from '@/presentation/shared/errorMapping';
import { revalidatePath } from 'next/cache';

export type ActionState = { success: boolean; error: string | null };

// useActionState 用に prevState を受ける2引数シグネチャ
export async function registerUserAction(
  _prevState: ActionState,
  formData: FormData,
): Promise<ActionState> {
  const rawInput = {
    firstName: formData.get('firstName'),
    lastName: formData.get('lastName'),
    email: formData.get('email'),
  };

  try {
    const inputDto = parseRegisterInput(rawInput);  // Controller と同じ検証
    const usecase = makeRegisterUserUseCase();       // 結線
    await usecase.execute(inputDto);                 // 実行
    revalidatePath('/users');                        // 読み取り側キャッシュを更新
    return { success: true, error: null };
  } catch (error) {
    const e = toPresentationError(error);            // Controller と同じ翻訳
    return { success: false, error: e.message };     // 生エラーは出さず安全なメッセージだけ返す
  }
}

フォームは状態(成功・失敗)を画面に出すため Client Component にし、useActionState で Action とつなぎます。

// src/app/users/RegisterForm.tsx
'use client';

import { useActionState } from 'react';
import { registerUserAction, ActionState } from './actions';

const initialState: ActionState = { success: false, error: null };

export function RegisterForm() {
  const [state, formAction] = useActionState(registerUserAction, initialState);

  return (
    <form action={formAction} className="mt-8 flex flex-col gap-2 max-w-sm">
      <h2 className="font-bold">ユーザー新規登録(書き込み)</h2>
      <input name="lastName" placeholder="姓" className="border p-1" required />
      <input name="firstName" placeholder="名" className="border p-1" required />
      <input name="email" placeholder="メールアドレス" className="border p-1" required />
      <button type="submit" className="bg-blue-500 text-white p-1">登録する</button>

      {state.error && <p className="text-red-500">{state.error}</p>}
      {state.success && <p className="text-green-600">登録しました</p>}
    </form>
  );
}

読み取り側のページは Server Component。QueryService で一覧を取り、書き込みフォームを置くだけです。

// src/app/users/page.tsx
import { makeUserQueryService } from '@/infrastructure/di/container';
import { RegisterForm } from './RegisterForm';

export default async function UsersPage() {
  const users = await makeUserQueryService().fetchOverviewList(); // 読み取り(副作用なし)

  return (
    <div className="p-6">
      <h1 className="text-xl font-bold">ユーザー一覧(読み取り)</h1>
      <ul>
        {users.map((u) => (
          <li key={u.id}>{u.fullName} ({u.email})</li>
        ))}
      </ul>
      <RegisterForm />
    </div>
  );
}

API エンドポイントは Controller に丸投げするだけ。ロジックは一切書きません。

// src/app/api/v1/users/route.ts
import { UserController } from '@/presentation/controllers/UserController';

const userController = new UserController();

export async function POST(request: Request) {
  return userController.register(request); // 右から左へ流すだけ
}

8. この設計で得られること(まとめ)

最後に、ここまでの構造が何を生むかを整理します。これがクリーンアーキテクチャの「ご利益」です。

  1. 付け替えが効く(交換性):DBは Composition Root でしか new されていません。実装を差し替えても、ドメイン・ユースケース・画面・APIは1行も変わりません。約束(interface)が変わらない限り、奥の発電所が何であっても掃除機は動きます。

  2. テストが速くて簡単(テスタビリティ):ユースケースは interface にしか依存しないので、本物のDBに繋がず、モックを注入して全部メモリ内でテストできます。

  3. 責務が一直線(単一責任):ドメインは業務ルール、ユースケースは流れ、プレゼンテーションは検証と表示、インフラは技術——と役割が重ならないので、変更の影響範囲が読めます。

  4. 壊れたときに原因がすぐわかる:エラーは層の境界で翻訳・吸収されるので、「DBの都合の例外が画面まで生で漏れる」ようなことが起きません。

初学者がつまずきやすいポイントの早見表

つまずき 正しい考え方
「interface はどこに置く?」 約束は内側(ドメイン)、実装は外側(インフラ)
new はどこでする?」 Composition Root だけ。他では interface を受け取る。
「検証はどの層?」 **入口(プレゼンテーション)**で第一防御、ドメインVOが最後の砦。
「日時や金額の整形は?」 プレゼンテーション。ユースケースは生データ(ISO文字列など)を返す。
「読み取りもユースケースを通す?」 単純な表示は **QueryService(CQRS)**で直接でよい。書き込みだけ厳格に。
app/ にロジックを書いていい?」 NG。app/ は薄い入口。actions.ts は例外的にプレゼンの入口を兼ねる。

発展:外部API(決済・メール送信など)を足したくなったら

このガイドでは扱いませんでしたが、Stripe決済やメール送信のような外部サービスを呼びたくなったら、リポジトリと同じ要領で「ポート(interface)」をドメインに足すだけです。たとえば PaymentService という interface をドメインに置き、StripePaymentGateway という実装をインフラに置き、Composition Root で結線する。ユースケースは interface だけを注入で受け取ります。ただし外部API呼び出しとDB保存をまたぐと「片方成功・片方失敗」の整合性(補償処理)を考える必要が出てくるため、まずは本ガイドの単一リポジトリ構成を理解してから踏み込むのがおすすめです。


クリーンアーキテクチャは最初こそファイル数が増えて面倒に見えますが、守るルールは「依存は外から内へ、約束は内・実装は外、結線は1か所」の3点だけです。この3点さえ守れば、残りは自然とこの形に落ち着きます。