Documentation Next.js

はじめに

Clean Architectureは、Robert C. Martin(Uncle Bob)が提唱したソフトウェア設計の原則です。この記事では、Next.jsプロジェクトにClean Architectureを適用し、保守性が高くテスト可能なアプリケーションを構築する方法を解説します。

Clean Architectureの概念

Clean Architectureの核心は「依存関係の方向」です。外側の層が内側の層に依存し、その逆は許容されません。

┌────────────────────────────────────┐
│         Infrastructure            │  外側(フレームワーク、DB)
│  ┌────────────────────────────┐   │
│  │       Application          │   │  ユースケース
│  │  ┌────────────────────┐    │   │
│  │  │      Domain        │    │   │  内側(ビジネスロジック)
│  │  │    (Entities)      │    │   │
│  │  └────────────────────┘    │   │
│  └────────────────────────────┘   │
└────────────────────────────────────┘

Next.jsでのフォルダ構成

src/
├── domain/                    # ドメイン層(ビジネスロジック)
│   ├── entities/              # エンティティ(ビジネスオブジェクト)
│   │   ├── User.ts
│   │   └── Post.ts
│   ├── repositories/          # リポジトリインターフェース
│   │   ├── IUserRepository.ts
│   │   └── IPostRepository.ts
│   └── services/              # ドメインサービス
│       └── AuthService.ts

├── application/               # アプリケーション層(ユースケース)
│   ├── usecases/
│   │   ├── user/
│   │   │   ├── CreateUserUseCase.ts
│   │   │   ├── GetUserUseCase.ts
│   │   │   └── UpdateUserUseCase.ts
│   │   └── post/
│   │       ├── CreatePostUseCase.ts
│   │       └── GetPostsUseCase.ts
│   └── dto/                   # データ転送オブジェクト
│       ├── CreateUserDTO.ts
│       └── UserResponseDTO.ts

├── infrastructure/            # インフラ層(外部依存)
│   ├── repositories/          # リポジトリ実装
│   │   ├── PrismaUserRepository.ts
│   │   └── PrismaPostRepository.ts
│   ├── database/
│   │   └── prisma.ts
│   └── external/              # 外部サービス
│       └── EmailService.ts

├── presentation/              # プレゼンテーション層(UI)
│   ├── components/
│   ├── hooks/
│   └── contexts/

└── app/                       # Next.js App Router
    ├── api/
    ├── (auth)/
    └── (main)/

ドメイン層の実装

エンティティ

// domain/entities/User.ts
export interface UserProps {
  id: string;
  email: string;
  name: string;
  createdAt: Date;
  updatedAt: Date;
}

export class User {
  private constructor(private readonly props: UserProps) {}

  // ファクトリメソッド
  static create(props: Omit<UserProps, 'id' | 'createdAt' | 'updatedAt'>): User {
    return new User({
      ...props,
      id: crypto.randomUUID(),
      createdAt: new Date(),
      updatedAt: new Date(),
    });
  }

  // 復元メソッド(DBからの読み込み用)
  static reconstruct(props: UserProps): User {
    return new User(props);
  }

  // ゲッター
  get id(): string {
    return this.props.id;
  }

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

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

  // ビジネスロジック
  updateName(newName: string): User {
    if (newName.length < 2) {
      throw new Error('Name must be at least 2 characters');
    }

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

  // シリアライズ
  toObject(): UserProps {
    return { ...this.props };
  }
}

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

// domain/repositories/IUserRepository.ts
import { User } from '../entities/User';

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

ドメインサービス

// domain/services/AuthService.ts
import { User } from '../entities/User';
import { IUserRepository } from '../repositories/IUserRepository';

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

  async validateEmail(email: string): Promise<boolean> {
    const existingUser = await this.userRepository.findByEmail(email);
    return existingUser === null;
  }

  async canUserPerformAction(userId: string, action: string): Promise<boolean> {
    const user = await this.userRepository.findById(userId);
    if (!user) return false;

    // ビジネスルールに基づいた権限チェック
    return true;
  }
}

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

ユースケース

// application/usecases/user/CreateUserUseCase.ts
import { User } from '@/domain/entities/User';
import { IUserRepository } from '@/domain/repositories/IUserRepository';
import { CreateUserDTO } from '@/application/dto/CreateUserDTO';
import { UserResponseDTO } from '@/application/dto/UserResponseDTO';

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

  async execute(dto: CreateUserDTO): Promise<UserResponseDTO> {
    // バリデーション
    const existingUser = await this.userRepository.findByEmail(dto.email);
    if (existingUser) {
      throw new Error('User with this email already exists');
    }

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

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

    // DTOに変換して返す
    return {
      id: savedUser.id,
      email: savedUser.email,
      name: savedUser.name,
    };
  }
}
// application/usecases/user/GetUserUseCase.ts
import { IUserRepository } from '@/domain/repositories/IUserRepository';
import { UserResponseDTO } from '@/application/dto/UserResponseDTO';

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

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

    if (!user) {
      return null;
    }

    return {
      id: user.id,
      email: user.email,
      name: user.name,
    };
  }
}

DTO

// application/dto/CreateUserDTO.ts
export interface CreateUserDTO {
  email: string;
  name: string;
}

// application/dto/UserResponseDTO.ts
export interface UserResponseDTO {
  id: string;
  email: string;
  name: string;
}

インフラ層の実装

リポジトリ実装

// infrastructure/repositories/PrismaUserRepository.ts
import { PrismaClient } from '@prisma/client';
import { User } from '@/domain/entities/User';
import { IUserRepository } from '@/domain/repositories/IUserRepository';

export class PrismaUserRepository implements IUserRepository {
  constructor(private readonly prisma: PrismaClient) {}

  async findById(id: string): Promise<User | null> {
    const userData = await this.prisma.user.findUnique({
      where: { id },
    });

    if (!userData) return null;

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

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

    if (!userData) return null;

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

  async findAll(): Promise<User[]> {
    const usersData = await this.prisma.user.findMany();

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

  async save(user: User): Promise<User> {
    const userData = user.toObject();

    const savedUser = await this.prisma.user.upsert({
      where: { id: userData.id },
      update: {
        email: userData.email,
        name: userData.name,
        updatedAt: userData.updatedAt,
      },
      create: {
        id: userData.id,
        email: userData.email,
        name: userData.name,
        createdAt: userData.createdAt,
        updatedAt: userData.updatedAt,
      },
    });

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

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

Prisma設定

// infrastructure/database/prisma.ts
import { PrismaClient } from '@prisma/client';

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === 'development' ? ['query'] : [],
  });

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma;
}

依存性注入(DI)コンテナ

// infrastructure/container.ts
import { PrismaClient } from '@prisma/client';
import { prisma } from './database/prisma';
import { PrismaUserRepository } from './repositories/PrismaUserRepository';
import { PrismaPostRepository } from './repositories/PrismaPostRepository';
import { CreateUserUseCase } from '@/application/usecases/user/CreateUserUseCase';
import { GetUserUseCase } from '@/application/usecases/user/GetUserUseCase';
import { IUserRepository } from '@/domain/repositories/IUserRepository';

// シンプルなDIコンテナ
class Container {
  private static instance: Container;
  private prismaClient: PrismaClient;

  private constructor() {
    this.prismaClient = prisma;
  }

  static getInstance(): Container {
    if (!Container.instance) {
      Container.instance = new Container();
    }
    return Container.instance;
  }

  // リポジトリ
  getUserRepository(): IUserRepository {
    return new PrismaUserRepository(this.prismaClient);
  }

  // ユースケース
  getCreateUserUseCase(): CreateUserUseCase {
    return new CreateUserUseCase(this.getUserRepository());
  }

  getGetUserUseCase(): GetUserUseCase {
    return new GetUserUseCase(this.getUserRepository());
  }
}

export const container = Container.getInstance();

APIルートでの使用

// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { container } from '@/infrastructure/container';

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();

    const createUserUseCase = container.getCreateUserUseCase();
    const user = await createUserUseCase.execute({
      email: body.email,
      name: body.name,
    });

    return NextResponse.json(user, { status: 201 });
  } catch (error) {
    if (error instanceof Error) {
      return NextResponse.json({ error: error.message }, { status: 400 });
    }
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
  }
}

export async function GET() {
  try {
    const userRepository = container.getUserRepository();
    const users = await userRepository.findAll();

    return NextResponse.json(
      users.map((user) => ({
        id: user.id,
        email: user.email,
        name: user.name,
      }))
    );
  } catch (error) {
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
  }
}
// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { container } from '@/infrastructure/container';

export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const getUserUseCase = container.getGetUserUseCase();
    const user = await getUserUseCase.execute(params.id);

    if (!user) {
      return NextResponse.json({ error: 'User not found' }, { status: 404 });
    }

    return NextResponse.json(user);
  } catch (error) {
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
  }
}

テストの実装

ユースケースのテスト

// __tests__/application/usecases/CreateUserUseCase.test.ts
import { CreateUserUseCase } from '@/application/usecases/user/CreateUserUseCase';
import { IUserRepository } from '@/domain/repositories/IUserRepository';
import { User } from '@/domain/entities/User';

// モックリポジトリ
class MockUserRepository implements IUserRepository {
  private users: User[] = [];

  async findById(id: string): Promise<User | null> {
    return this.users.find((u) => u.id === id) || null;
  }

  async findByEmail(email: string): Promise<User | null> {
    return this.users.find((u) => u.email === email) || null;
  }

  async findAll(): Promise<User[]> {
    return this.users;
  }

  async save(user: User): Promise<User> {
    this.users.push(user);
    return user;
  }

  async delete(id: string): Promise<void> {
    this.users = this.users.filter((u) => u.id !== id);
  }
}

describe('CreateUserUseCase', () => {
  let useCase: CreateUserUseCase;
  let mockRepository: MockUserRepository;

  beforeEach(() => {
    mockRepository = new MockUserRepository();
    useCase = new CreateUserUseCase(mockRepository);
  });

  it('should create a new user', async () => {
    const result = await useCase.execute({
      email: 'test@example.com',
      name: 'Test User',
    });

    expect(result.email).toBe('test@example.com');
    expect(result.name).toBe('Test User');
    expect(result.id).toBeDefined();
  });

  it('should throw error if email already exists', async () => {
    await useCase.execute({
      email: 'test@example.com',
      name: 'Test User',
    });

    await expect(
      useCase.execute({
        email: 'test@example.com',
        name: 'Another User',
      })
    ).rejects.toThrow('User with this email already exists');
  });
});

エンティティのテスト

// __tests__/domain/entities/User.test.ts
import { User } from '@/domain/entities/User';

describe('User Entity', () => {
  it('should create a user with valid data', () => {
    const user = User.create({
      email: 'test@example.com',
      name: 'Test User',
    });

    expect(user.email).toBe('test@example.com');
    expect(user.name).toBe('Test User');
    expect(user.id).toBeDefined();
  });

  it('should update name correctly', () => {
    const user = User.create({
      email: 'test@example.com',
      name: 'Old Name',
    });

    const updatedUser = user.updateName('New Name');

    expect(updatedUser.name).toBe('New Name');
  });

  it('should throw error for invalid name', () => {
    const user = User.create({
      email: 'test@example.com',
      name: 'Test User',
    });

    expect(() => user.updateName('A')).toThrow(
      'Name must be at least 2 characters'
    );
  });
});

Server Actionsでの使用

// app/actions/user.ts
'use server';

import { container } from '@/infrastructure/container';
import { revalidatePath } from 'next/cache';

export async function createUser(formData: FormData) {
  const email = formData.get('email') as string;
  const name = formData.get('name') as string;

  const createUserUseCase = container.getCreateUserUseCase();

  try {
    const user = await createUserUseCase.execute({ email, name });
    revalidatePath('/users');
    return { success: true, user };
  } catch (error) {
    if (error instanceof Error) {
      return { success: false, error: error.message };
    }
    return { success: false, error: 'Unknown error' };
  }
}

まとめ

Clean ArchitectureをNext.jsに適用するポイントをまとめます。

  • レイヤー分離: ドメイン、アプリケーション、インフラ、プレゼンテーション層を明確に分離
  • 依存性の逆転: インフラ層がドメイン層のインターフェースに依存
  • テスト容易性: モックを使ったユニットテストが容易
  • DIコンテナ: 依存関係を一元管理
  • 再利用性: ビジネスロジックをUIから分離して再利用可能に

参考文献

円