Documentation Next.js

はじめに

ドメイン駆動設計(DDD)は、複雑なビジネスロジックを整理し、保守性の高いソフトウェアを構築するための設計手法です。Next.js App Routerの特性を活かしたDDD実践方法を、具体的なコード例とともに解説します。

ディレクトリ構成

src/
├── app/                    # Next.js App Router
│   ├── (routes)/           # ルートグループ
│   │   └── users/
│   │       ├── page.tsx
│   │       └── [id]/
│   │           └── page.tsx
│   └── api/                # APIルート(外部連携用)
├── domain/                 # ドメイン層
│   ├── models/             # エンティティ・値オブジェクト
│   │   ├── user/
│   │   │   ├── User.ts
│   │   │   ├── UserId.ts
│   │   │   └── Email.ts
│   │   └── order/
│   │       ├── Order.ts
│   │       └── OrderItem.ts
│   ├── repositories/       # リポジトリインターフェース
│   │   └── IUserRepository.ts
│   └── services/           # ドメインサービス
│       └── UserDomainService.ts
├── application/            # アプリケーション層
│   ├── usecases/           # ユースケース
│   │   └── user/
│   │       ├── CreateUserUseCase.ts
│   │       └── GetUserUseCase.ts
│   └── dto/                # データ転送オブジェクト
│       └── UserDTO.ts
├── infrastructure/         # インフラ層
│   ├── repositories/       # リポジトリ実装
│   │   └── PrismaUserRepository.ts
│   └── database/
│       └── prisma.ts
├── presentation/           # プレゼンテーション層
│   ├── components/
│   └── actions/            # Server Actions
│       └── userActions.ts
└── lib/                    # 共通ユーティリティ
    └── errors.ts

ドメイン層の実装

値オブジェクト

// domain/models/user/Email.ts
export class Email {
  private readonly value: string;

  private constructor(value: string) {
    this.value = value;
  }

  static create(value: string): Email {
    if (!value || !this.isValid(value)) {
      throw new Error('Invalid email format');
    }
    return new Email(value.toLowerCase());
  }

  private static isValid(email: string): boolean {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return emailRegex.test(email);
  }

  getValue(): string {
    return this.value;
  }

  equals(other: Email): boolean {
    return this.value === other.value;
  }

  toString(): string {
    return this.value;
  }
}
// domain/models/user/UserId.ts
import { randomUUID } from 'crypto';

export class UserId {
  private readonly value: string;

  private constructor(value: string) {
    this.value = value;
  }

  static create(value?: string): UserId {
    return new UserId(value ?? randomUUID());
  }

  static reconstruct(value: string): UserId {
    if (!value) {
      throw new Error('UserId cannot be empty');
    }
    return new UserId(value);
  }

  getValue(): string {
    return this.value;
  }

  equals(other: UserId): boolean {
    return this.value === other.value;
  }

  toString(): string {
    return this.value;
  }
}

エンティティ

// domain/models/user/User.ts
import { UserId } from './UserId';
import { Email } from './Email';

interface UserProps {
  id: UserId;
  name: string;
  email: Email;
  createdAt: Date;
  updatedAt: Date;
}

export class User {
  private readonly props: UserProps;

  private constructor(props: UserProps) {
    this.props = props;
  }

  static create(params: {
    name: string;
    email: string;
  }): User {
    if (!params.name || params.name.length < 2) {
      throw new Error('Name must be at least 2 characters');
    }

    const now = new Date();
    return new User({
      id: UserId.create(),
      name: params.name,
      email: Email.create(params.email),
      createdAt: now,
      updatedAt: now,
    });
  }

  static reconstruct(params: {
    id: string;
    name: string;
    email: string;
    createdAt: Date;
    updatedAt: Date;
  }): User {
    return new User({
      id: UserId.reconstruct(params.id),
      name: params.name,
      email: Email.create(params.email),
      createdAt: params.createdAt,
      updatedAt: params.updatedAt,
    });
  }

  get id(): UserId {
    return this.props.id;
  }

  get name(): string {
    return this.props.name;
  }

  get email(): Email {
    return this.props.email;
  }

  get createdAt(): Date {
    return this.props.createdAt;
  }

  get updatedAt(): Date {
    return this.props.updatedAt;
  }

  changeName(newName: string): User {
    if (!newName || newName.length < 2) {
      throw new Error('Name must be at least 2 characters');
    }

    return new User({
      ...this.props,
      name: newName,
      updatedAt: new Date(),
    });
  }

  changeEmail(newEmail: string): User {
    return new User({
      ...this.props,
      email: Email.create(newEmail),
      updatedAt: new Date(),
    });
  }
}

リポジトリインターフェース

// domain/repositories/IUserRepository.ts
import { User } from '../models/user/User';
import { UserId } from '../models/user/UserId';
import { Email } from '../models/user/Email';

export interface IUserRepository {
  findById(id: UserId): Promise<User | null>;
  findByEmail(email: Email): Promise<User | null>;
  findAll(): Promise<User[]>;
  save(user: User): Promise<void>;
  delete(id: UserId): Promise<void>;
  exists(email: Email): Promise<boolean>;
}

ドメインサービス

// domain/services/UserDomainService.ts
import { User } from '../models/user/User';
import { Email } from '../models/user/Email';
import { IUserRepository } from '../repositories/IUserRepository';

export class UserDomainService {
  constructor(private readonly userRepository: IUserRepository) {}

  async isEmailUnique(email: Email): Promise<boolean> {
    return !(await this.userRepository.exists(email));
  }

  async canCreateUser(email: string): Promise<boolean> {
    const emailVO = Email.create(email);
    return this.isEmailUnique(emailVO);
  }
}

インフラ層の実装

Prismaリポジトリ

// infrastructure/repositories/PrismaUserRepository.ts
import { prisma } from '../database/prisma';
import { IUserRepository } from '@/domain/repositories/IUserRepository';
import { User } from '@/domain/models/user/User';
import { UserId } from '@/domain/models/user/UserId';
import { Email } from '@/domain/models/user/Email';

export class PrismaUserRepository implements IUserRepository {
  async findById(id: UserId): Promise<User | null> {
    const data = await prisma.user.findUnique({
      where: { id: id.getValue() },
    });

    if (!data) return null;

    return User.reconstruct({
      id: data.id,
      name: data.name,
      email: data.email,
      createdAt: data.createdAt,
      updatedAt: data.updatedAt,
    });
  }

  async findByEmail(email: Email): Promise<User | null> {
    const data = await prisma.user.findUnique({
      where: { email: email.getValue() },
    });

    if (!data) return null;

    return User.reconstruct({
      id: data.id,
      name: data.name,
      email: data.email,
      createdAt: data.createdAt,
      updatedAt: data.updatedAt,
    });
  }

  async findAll(): Promise<User[]> {
    const data = await prisma.user.findMany({
      orderBy: { createdAt: 'desc' },
    });

    return data.map((d) =>
      User.reconstruct({
        id: d.id,
        name: d.name,
        email: d.email,
        createdAt: d.createdAt,
        updatedAt: d.updatedAt,
      })
    );
  }

  async save(user: User): Promise<void> {
    await prisma.user.upsert({
      where: { id: user.id.getValue() },
      update: {
        name: user.name,
        email: user.email.getValue(),
        updatedAt: user.updatedAt,
      },
      create: {
        id: user.id.getValue(),
        name: user.name,
        email: user.email.getValue(),
        createdAt: user.createdAt,
        updatedAt: user.updatedAt,
      },
    });
  }

  async delete(id: UserId): Promise<void> {
    await prisma.user.delete({
      where: { id: id.getValue() },
    });
  }

  async exists(email: Email): Promise<boolean> {
    const count = await prisma.user.count({
      where: { email: email.getValue() },
    });
    return count > 0;
  }
}

アプリケーション層の実装

DTO

// application/dto/UserDTO.ts
import { User } from '@/domain/models/user/User';

export interface UserDTO {
  id: string;
  name: string;
  email: string;
  createdAt: string;
  updatedAt: string;
}

export function toUserDTO(user: User): UserDTO {
  return {
    id: user.id.getValue(),
    name: user.name,
    email: user.email.getValue(),
    createdAt: user.createdAt.toISOString(),
    updatedAt: user.updatedAt.toISOString(),
  };
}

export interface CreateUserInput {
  name: string;
  email: string;
}

export interface UpdateUserInput {
  id: string;
  name?: string;
  email?: string;
}

ユースケース

// application/usecases/user/CreateUserUseCase.ts
import { User } from '@/domain/models/user/User';
import { IUserRepository } from '@/domain/repositories/IUserRepository';
import { UserDomainService } from '@/domain/services/UserDomainService';
import { CreateUserInput, UserDTO, toUserDTO } from '@/application/dto/UserDTO';

export class CreateUserUseCase {
  constructor(
    private readonly userRepository: IUserRepository,
    private readonly userDomainService: UserDomainService
  ) {}

  async execute(input: CreateUserInput): Promise<UserDTO> {
    // ビジネスルール検証
    const canCreate = await this.userDomainService.canCreateUser(input.email);
    if (!canCreate) {
      throw new Error('Email is already in use');
    }

    // エンティティ生成
    const user = User.create({
      name: input.name,
      email: input.email,
    });

    // 永続化
    await this.userRepository.save(user);

    return toUserDTO(user);
  }
}
// application/usecases/user/GetUserUseCase.ts
import { IUserRepository } from '@/domain/repositories/IUserRepository';
import { UserId } from '@/domain/models/user/UserId';
import { UserDTO, toUserDTO } from '@/application/dto/UserDTO';

export class GetUserUseCase {
  constructor(private readonly userRepository: IUserRepository) {}

  async execute(id: string): Promise<UserDTO | null> {
    const userId = UserId.reconstruct(id);
    const user = await this.userRepository.findById(userId);

    if (!user) return null;

    return toUserDTO(user);
  }
}

export class GetUsersUseCase {
  constructor(private readonly userRepository: IUserRepository) {}

  async execute(): Promise<UserDTO[]> {
    const users = await this.userRepository.findAll();
    return users.map(toUserDTO);
  }
}
// application/usecases/user/UpdateUserUseCase.ts
import { IUserRepository } from '@/domain/repositories/IUserRepository';
import { UserDomainService } from '@/domain/services/UserDomainService';
import { UserId } from '@/domain/models/user/UserId';
import { Email } from '@/domain/models/user/Email';
import { UpdateUserInput, UserDTO, toUserDTO } from '@/application/dto/UserDTO';

export class UpdateUserUseCase {
  constructor(
    private readonly userRepository: IUserRepository,
    private readonly userDomainService: UserDomainService
  ) {}

  async execute(input: UpdateUserInput): Promise<UserDTO> {
    const userId = UserId.reconstruct(input.id);
    const existingUser = await this.userRepository.findById(userId);

    if (!existingUser) {
      throw new Error('User not found');
    }

    let user = existingUser;

    if (input.name) {
      user = user.changeName(input.name);
    }

    if (input.email && input.email !== existingUser.email.getValue()) {
      const canUpdate = await this.userDomainService.canCreateUser(input.email);
      if (!canUpdate) {
        throw new Error('Email is already in use');
      }
      user = user.changeEmail(input.email);
    }

    await this.userRepository.save(user);

    return toUserDTO(user);
  }
}

プレゼンテーション層(Server Actions)

// presentation/actions/userActions.ts
'use server';

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { PrismaUserRepository } from '@/infrastructure/repositories/PrismaUserRepository';
import { UserDomainService } from '@/domain/services/UserDomainService';
import { CreateUserUseCase } from '@/application/usecases/user/CreateUserUseCase';
import { UpdateUserUseCase } from '@/application/usecases/user/UpdateUserUseCase';
import { GetUsersUseCase, GetUserUseCase } from '@/application/usecases/user/GetUserUseCase';
import { UserId } from '@/domain/models/user/UserId';

// 依存性の解決(実際のプロジェクトではDIコンテナを使用)
function createDependencies() {
  const userRepository = new PrismaUserRepository();
  const userDomainService = new UserDomainService(userRepository);
  return { userRepository, userDomainService };
}

export async function createUser(formData: FormData) {
  const { userRepository, userDomainService } = createDependencies();
  const useCase = new CreateUserUseCase(userRepository, userDomainService);

  const name = formData.get('name') as string;
  const email = formData.get('email') as string;

  try {
    await useCase.execute({ name, email });
    revalidatePath('/users');
    redirect('/users');
  } catch (error) {
    if (error instanceof Error) {
      return { error: error.message };
    }
    return { error: 'An unexpected error occurred' };
  }
}

export async function updateUser(formData: FormData) {
  const { userRepository, userDomainService } = createDependencies();
  const useCase = new UpdateUserUseCase(userRepository, userDomainService);

  const id = formData.get('id') as string;
  const name = formData.get('name') as string;
  const email = formData.get('email') as string;

  try {
    await useCase.execute({ id, name, email });
    revalidatePath('/users');
    revalidatePath(`/users/${id}`);
  } catch (error) {
    if (error instanceof Error) {
      return { error: error.message };
    }
    return { error: 'An unexpected error occurred' };
  }
}

export async function deleteUser(formData: FormData) {
  const { userRepository } = createDependencies();

  const id = formData.get('id') as string;
  const userId = UserId.reconstruct(id);

  await userRepository.delete(userId);
  revalidatePath('/users');
}

export async function getUsers() {
  const { userRepository } = createDependencies();
  const useCase = new GetUsersUseCase(userRepository);
  return useCase.execute();
}

export async function getUser(id: string) {
  const { userRepository } = createDependencies();
  const useCase = new GetUserUseCase(userRepository);
  return useCase.execute(id);
}

ページコンポーネント

// app/(routes)/users/page.tsx
import Link from 'next/link';
import { getUsers } from '@/presentation/actions/userActions';
import { UserList } from '@/presentation/components/UserList';

export default async function UsersPage() {
  const users = await getUsers();

  return (
    <div className="container mx-auto px-4 py-8">
      <div className="flex justify-between items-center mb-6">
        <h1 className="text-2xl font-bold">ユーザー一覧</h1>
        <Link
          href="/users/new"
          className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
        >
          新規作成
        </Link>
      </div>

      <UserList users={users} />
    </div>
  );
}
// presentation/components/UserList.tsx
import Link from 'next/link';
import { UserDTO } from '@/application/dto/UserDTO';
import { deleteUser } from '@/presentation/actions/userActions';

interface Props {
  users: UserDTO[];
}

export function UserList({ users }: Props) {
  return (
    <div className="overflow-x-auto">
      <table className="min-w-full bg-white border">
        <thead>
          <tr className="bg-gray-100">
            <th className="px-4 py-2 text-left">名前</th>
            <th className="px-4 py-2 text-left">メール</th>
            <th className="px-4 py-2 text-left">登録日</th>
            <th className="px-4 py-2 text-left">操作</th>
          </tr>
        </thead>
        <tbody>
          {users.map((user) => (
            <tr key={user.id} className="border-t">
              <td className="px-4 py-2">
                <Link href={`/users/${user.id}`} className="text-blue-600 hover:underline">
                  {user.name}
                </Link>
              </td>
              <td className="px-4 py-2">{user.email}</td>
              <td className="px-4 py-2">
                {new Date(user.createdAt).toLocaleDateString('ja-JP')}
              </td>
              <td className="px-4 py-2">
                <form action={deleteUser}>
                  <input type="hidden" name="id" value={user.id} />
                  <button
                    type="submit"
                    className="text-red-600 hover:underline"
                  >
                    削除
                  </button>
                </form>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
// app/(routes)/users/new/page.tsx
import { createUser } from '@/presentation/actions/userActions';
import { UserForm } from '@/presentation/components/UserForm';

export default function NewUserPage() {
  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-2xl font-bold mb-6">ユーザー作成</h1>
      <UserForm action={createUser} />
    </div>
  );
}
// presentation/components/UserForm.tsx
'use client';

import { useFormStatus } from 'react-dom';
import { UserDTO } from '@/application/dto/UserDTO';

interface Props {
  action: (formData: FormData) => Promise<{ error?: string } | void>;
  user?: UserDTO;
}

function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button
      type="submit"
      disabled={pending}
      className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
    >
      {pending ? '保存中...' : '保存'}
    </button>
  );
}

export function UserForm({ action, user }: Props) {
  return (
    <form action={action} className="space-y-4 max-w-md">
      {user && <input type="hidden" name="id" value={user.id} />}

      <div>
        <label htmlFor="name" className="block text-sm font-medium mb-1">
          名前
        </label>
        <input
          type="text"
          id="name"
          name="name"
          defaultValue={user?.name}
          required
          minLength={2}
          className="w-full border rounded px-3 py-2"
        />
      </div>

      <div>
        <label htmlFor="email" className="block text-sm font-medium mb-1">
          メールアドレス
        </label>
        <input
          type="email"
          id="email"
          name="email"
          defaultValue={user?.email}
          required
          className="w-full border rounded px-3 py-2"
        />
      </div>

      <SubmitButton />
    </form>
  );
}

まとめ

レイヤー責務Next.jsでの実装
ドメイン層ビジネスロジックエンティティ、値オブジェクト、ドメインサービス
アプリケーション層ユースケースUseCase クラス、DTO
インフラ層永続化・外部連携Prismaリポジトリ
プレゼンテーション層UI・入出力Server Actions、React Components

参考文献

円