コンテンツにスキップ

フロントエンドのための Clean Architecture

はじめに

Clean Architectureは、関心の分離とフレームワーク、UI、外部システムからの独立性を重視するソフトウェア設計哲学です。フロントエンド開発において、保守可能でテスト可能なアプリケーションを構築するための体系的なアプローチを提供します。

核となる原則

1. 独立性

  • フレームワークからの独立性: ビジネスロジックはReact、Vue、その他のフレームワークに依存しない
  • UIからの独立性: ドメインロジックはUI実装に関係なく動作する
  • データベースからの独立性: データ層はビジネスルールに影響を与えることなく交換可能
  • 外部機関からの独立性: 外部システムがアーキテクチャを決定しない

2. 依存性ルール

依存性は内側を向く: 外側の層は内側の層に依存できますが、その逆は決してありません。

┌─────────────────────────────────────────┐
│    Infrastructure (External)            │  ← フレームワーク、API、DB
├─────────────────────────────────────────┤
│    Interface Adapters (Glue)            │  ← コントローラー、プレゼンター
├─────────────────────────────────────────┤
│    Application Business Rules (UseCases)│  ← アプリケーションロジック
├─────────────────────────────────────────┤
│    Enterprise Business Rules (Entities) │  ← コアドメイン
└─────────────────────────────────────────┘

フロントエンドにおける層

第1層: Entities (Domain)

純粋なビジネスオブジェクト、依存性なし。

// core/domain/user.ts
export class User {
  constructor(
    public readonly id: string,
    public readonly name: string,
    public readonly email: string
  ) {
    this.validate();
  }

  private validate(): void {
    if (!this.email.includes('@')) {
      throw new Error('Invalid email');
    }
    if (this.name.length < 2) {
      throw new Error('Name too short');
    }
  }

  canAccessAdminPanel(): boolean {
    return this.email.endsWith('@company.com');
  }
}

特徴: - 外部依存性なし - ビジネスルールを含む - フレームワーク非依存 - 容易にテスト可能

第2層: Use Cases (Application)

ドメインエンティティをオーケストレーションするアプリケーション固有のビジネスルール

// core/usecases/registerUser.ts
export interface RegisterUserInput {
  name: string;
  email: string;
  password: string;
}

export interface UserRepository {
  save(user: User): Promise<void>;
  findByEmail(email: string): Promise<User | null>;
}

export class RegisterUserUseCase {
  constructor(
    private userRepository: UserRepository,
    private emailService: EmailService,
    private passwordHasher: PasswordHasher
  ) {}

  async execute(input: RegisterUserInput): Promise<User> {
    // 1. ビジネスルールの検証
    const existingUser = await this.userRepository.findByEmail(input.email);
    if (existingUser) {
      throw new Error('Email already registered');
    }

    // 2. ドメインエンティティの作成
    const user = new User(
      generateId(),
      input.name,
      input.email
    );

    // 3. パスワードのハッシュ化(インフラストラクチャの関心事)
    const hashedPassword = await this.passwordHasher.hash(input.password);

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

    // 5. 通知の送信
    await this.emailService.sendWelcomeEmail(user.email);

    return user;
  }
}

特徴: - ドメインとインターフェースのみに依存 - ビジネス操作をオーケストレーション - UIやフレームワークのコードなし - 純粋なTypeScript/JavaScript

第3層: Interface Adapters

Use casesと外部システム間でデータを変換します。

Controllers (Input)

// adapters/controllers/userController.ts
export class UserController {
  constructor(private registerUseCase: RegisterUserUseCase) {}

  async handleRegistration(request: Request): Promise<Response> {
    try {
      const { name, email, password } = await request.json();

      const user = await this.registerUseCase.execute({
        name,
        email,
        password
      });

      return Response.json({
        success: true,
        userId: user.id
      }, { status: 201 });
    } catch (error) {
      return Response.json({
        success: false,
        error: error.message
      }, { status: 400 });
    }
  }
}

Presenters (Output)

// adapters/presenters/userPresenter.ts
export class UserPresenter {
  present(user: User): UserViewModel {
    return {
      id: user.id,
      displayName: user.name,
      email: user.email,
      isAdmin: user.canAccessAdminPanel()
    };
  }

  presentList(users: User[]): UserListViewModel {
    return {
      users: users.map(u => this.present(u)),
      total: users.length
    };
  }
}

export interface UserViewModel {
  id: string;
  displayName: string;
  email: string;
  isAdmin: boolean;
}

第4層: Infrastructure

外部システムとフレームワーク: データベース、API、UIフレームワークなど。

Repositoryの実装

// infrastructure/repositories/apiUserRepository.ts
import { User } from '@/core/domain/user';
import { UserRepository } from '@/core/usecases/registerUser';

export class ApiUserRepository implements UserRepository {
  constructor(private apiClient: ApiClient) {}

  async save(user: User): Promise<void> {
    await this.apiClient.post('/users', {
      id: user.id,
      name: user.name,
      email: user.email
    });
  }

  async findByEmail(email: string): Promise<User | null> {
    const response = await this.apiClient.get(`/users?email=${email}`);
    if (!response.data) return null;

    return new User(
      response.data.id,
      response.data.name,
      response.data.email
    );
  }
}

UIコンポーネント (React)

// infrastructure/ui/components/RegisterForm.tsx
'use client';

import { useState } from 'react';
import { registerUserAction } from '@/infrastructure/ui/actions/userActions';

export function RegisterForm() {
  const [error, setError] = useState('');

  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);

    const result = await registerUserAction(formData);
    if (!result.success) {
      setError(result.error);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" placeholder="Name" required />
      <input name="email" type="email" placeholder="Email" required />
      <input name="password" type="password" placeholder="Password" required />
      <button type="submit">Register</button>
      {error && <p className="error">{error}</p>}
    </form>
  );
}

Next.js Clean Architecture構造

推奨フォルダ構造

src/
├── app/                          # Next.js App Router
│   ├── (auth)/
│   │   └── register/
│   │       └── page.tsx          # UIエントリーポイント
│   └── api/
│       └── users/
│           └── route.ts          # APIルートハンドラー
├── core/                         # ビジネスロジック(フレームワーク非依存)
│   ├── domain/                   # エンティティ
│   │   ├── user.ts
│   │   ├── product.ts
│   │   └── order.ts
│   ├── usecases/                 # アプリケーションロジック
│   │   ├── registerUser.ts
│   │   ├── createOrder.ts
│   │   └── interfaces/           # Use Caseインターフェース
│   │       ├── repositories.ts
│   │       └── services.ts
│   └── common/                   # 共有ドメインロジック
│       ├── errors.ts
│       └── types.ts
├── infrastructure/               # 外部システムとフレームワーク
│   ├── repositories/
│   │   ├── apiUserRepository.ts
│   │   ├── localStorageRepository.ts
│   │   └── mockRepository.ts
│   ├── services/
│   │   ├── emailService.ts
│   │   └── authService.ts
│   ├── api/
│   │   └── client.ts
│   └── ui/                       # React/Next.js固有
│       ├── components/
│       ├── hooks/
│       └── actions/              # Server Actions
├── adapters/                     # Interface Adapters
│   ├── controllers/
│   ├── presenters/
│   └── viewModels/
└── config/                       # 設定
    ├── dependencies.ts           # Dependency Injection
    └── constants.ts

Dependency Injection

シンプルなDIコンテナ

// config/dependencies.ts
import { RegisterUserUseCase } from '@/core/usecases/registerUser';
import { ApiUserRepository } from '@/infrastructure/repositories/apiUserRepository';
import { EmailService } from '@/infrastructure/services/emailService';
import { PasswordHasher } from '@/infrastructure/services/passwordHasher';
import { ApiClient } from '@/infrastructure/api/client';

class DependencyContainer {
  private static instance: DependencyContainer;

  private _apiClient?: ApiClient;
  private _userRepository?: ApiUserRepository;
  private _emailService?: EmailService;
  private _passwordHasher?: PasswordHasher;

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

  get apiClient(): ApiClient {
    if (!this._apiClient) {
      this._apiClient = new ApiClient(process.env.NEXT_PUBLIC_API_URL!);
    }
    return this._apiClient;
  }

  get userRepository(): ApiUserRepository {
    if (!this._userRepository) {
      this._userRepository = new ApiUserRepository(this.apiClient);
    }
    return this._userRepository;
  }

  get emailService(): EmailService {
    if (!this._emailService) {
      this._emailService = new EmailService();
    }
    return this._emailService;
  }

  get passwordHasher(): PasswordHasher {
    if (!this._passwordHasher) {
      this._passwordHasher = new PasswordHasher();
    }
    return this._passwordHasher;
  }

  getRegisterUserUseCase(): RegisterUserUseCase {
    return new RegisterUserUseCase(
      this.userRepository,
      this.emailService,
      this.passwordHasher
    );
  }
}

export const container = DependencyContainer.getInstance();

Server Actionsでの使用

// infrastructure/ui/actions/userActions.ts
'use server';

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

export async function registerUserAction(formData: FormData) {
  const useCase = container.getRegisterUserUseCase();

  try {
    const user = await useCase.execute({
      name: formData.get('name') as string,
      email: formData.get('email') as string,
      password: formData.get('password') as string
    });

    revalidatePath('/users');
    return { success: true, userId: user.id };
  } catch (error) {
    return { success: false, error: error.message };
  }
}

Clean Architecture with RSC + Server Actions

Server Components (Query/Read)

// app/products/page.tsx (Server Component)
import { container } from '@/config/dependencies';
import { ProductCard } from '@/infrastructure/ui/components/ProductCard';

export default async function ProductsPage() {
  // サーバーでuse caseを実行
  const getProducts = container.getGetProductsUseCase();
  const products = await getProducts.execute();

  return (
    <div>
      <h1>Products</h1>
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

Server Actions (Command/Write)

// infrastructure/ui/actions/productActions.ts
'use server';

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

export async function createProductAction(formData: FormData) {
  const useCase = container.getCreateProductUseCase();

  const result = await useCase.execute({
    name: formData.get('name') as string,
    price: Number(formData.get('price')),
    description: formData.get('description') as string
  });

  revalidatePath('/products');
  return result;
}

Client Components (Interactive UI)

// infrastructure/ui/components/CreateProductForm.tsx
'use client';

import { useFormState } from 'react-dom';
import { createProductAction } from '../actions/productActions';

export function CreateProductForm() {
  const [state, formAction] = useFormState(createProductAction, null);

  return (
    <form action={formAction}>
      <input name="name" placeholder="Product Name" required />
      <input name="price" type="number" placeholder="Price" required />
      <textarea name="description" placeholder="Description" />
      <button type="submit">Create Product</button>
      {state?.success && <p>Product created!</p>}
      {state?.error && <p className="error">{state.error}</p>}
    </form>
  );
}

Clean Architectureのテスト

ドメイン層のテスト(純粋)

// core/domain/user.test.ts
import { User } from './user';

describe('User', () => {
  it('creates valid user', () => {
    const user = new User('1', 'John Doe', 'john@example.com');
    expect(user.name).toBe('John Doe');
  });

  it('throws error for invalid email', () => {
    expect(() => new User('1', 'John', 'invalid-email'))
      .toThrow('Invalid email');
  });

  it('identifies admin users', () => {
    const admin = new User('1', 'Admin', 'admin@company.com');
    const regular = new User('2', 'User', 'user@gmail.com');

    expect(admin.canAccessAdminPanel()).toBe(true);
    expect(regular.canAccessAdminPanel()).toBe(false);
  });
});

Use Caseのテスト(モックあり)

// core/usecases/registerUser.test.ts
import { RegisterUserUseCase } from './registerUser';
import { MockUserRepository } from '@/infrastructure/repositories/mockRepository';
import { MockEmailService } from '@/infrastructure/services/mockEmailService';
import { MockPasswordHasher } from '@/infrastructure/services/mockPasswordHasher';

describe('RegisterUserUseCase', () => {
  let useCase: RegisterUserUseCase;
  let mockRepo: MockUserRepository;
  let mockEmail: MockEmailService;
  let mockHasher: MockPasswordHasher;

  beforeEach(() => {
    mockRepo = new MockUserRepository();
    mockEmail = new MockEmailService();
    mockHasher = new MockPasswordHasher();
    useCase = new RegisterUserUseCase(mockRepo, mockEmail, mockHasher);
  });

  it('registers new user successfully', async () => {
    const input = {
      name: 'John Doe',
      email: 'john@example.com',
      password: 'password123'
    };

    const user = await useCase.execute(input);

    expect(user.name).toBe('John Doe');
    expect(mockRepo.users).toHaveLength(1);
    expect(mockEmail.sentEmails).toHaveLength(1);
  });

  it('throws error when email already exists', async () => {
    mockRepo.users.push(new User('1', 'Existing', 'john@example.com'));

    await expect(useCase.execute({
      name: 'John Doe',
      email: 'john@example.com',
      password: 'password123'
    })).rejects.toThrow('Email already registered');
  });
});

Clean Architectureの利点

1. テスタビリティ

// UIフレームワークなしで簡単にテスト可能
const useCase = new CreateOrderUseCase(mockRepository);
const result = await useCase.execute(input);
expect(result.totalPrice).toBe(100);

2. 柔軟性

ビジネスロジックを変更することなく実装を交換可能:

// 開発環境: Mockリポジトリ
const userRepo = new MockUserRepository();

// 本番環境: APIリポジトリ
const userRepo = new ApiUserRepository(apiClient);

// 同じuse caseが両方で動作
const useCase = new RegisterUserUseCase(userRepo, emailService, hasher);

3. フレームワーク非依存性

// ビジネスロジックはReact、Next.js、その他のフレームワークを知らない
export class CalculateShippingCost {
  execute(weight: number, distance: number): number {
    return (weight * 0.5) + (distance * 0.1);
  }
}

// どこでも使用可能: React、Vue、Node.js、Denoなど

よくある間違い

❌ 層の混在

// やってはいけない: ドメイン層にReact hooks
export class User {
  async save() {
    const [loading, setLoading] = useState(false); // ❌ 間違い!
    await fetch('/api/users', { ... });
  }
}

❌ フレームワークへの直接依存

// やってはいけない: Use CaseにNext.js固有のコード
export class LoginUseCase {
  async execute(email: string, password: string) {
    redirect('/dashboard'); // ❌ 間違い! Next.js依存
  }
}

❌ Use Casesのバイパス

// やってはいけない: UIからリポジトリに直接アクセス
'use client';
export function UserList() {
  const users = await userRepository.findAll(); // ❌ 間違い!
  return <div>{users.map(...)}</div>;
}

// すべき: Use Caseのオーケストレーション
export function UserList() {
  const getUsers = container.getGetUsersUseCase();
  const users = await getUsers.execute();
  return <div>{users.map(...)}</div>;
}

Clean Architectureを使用するタイミング

使用すべき場合:

  • 中〜大規模アプリケーション
  • 長期的なメンテナンスが予想される
  • 複数の開発者/チーム
  • 複雑なビジネスロジック
  • 実装の交換が必要(APIプロバイダー、データベース)
  • 高いテスタビリティが必要

スキップすべき場合:

  • シンプルなランディングページ
  • 厳しい期限のプロトタイプ/MVP
  • 非常に小規模なアプリケーション(10ページ未満)
  • チームがパターンに不慣れ(学習曲線が急すぎる)

段階的な採用

Clean Architectureを一度にすべて採用する必要はありません:

  1. Use Casesから開始 - ビジネスロジックを関数に抽出
  2. Repository Patternの追加 - API呼び出しを抽象化
  3. Domain Entitiesの導入 - 検証をドメインオブジェクトに移動
  4. DIの実装 - 柔軟性のためにdependency injectionを追加
  5. 層の洗練 - 徐々に層の境界を強制

リソース

まとめ

フロントエンドにおけるClean Architecture: - 関心の分離: 各層は単一の責任を持つ - 依存性ルール: 内側の層は外側の層を知らない - フレームワーク非依存性: ビジネスロジックは純粋なTypeScript - テスタビリティ: UIや外部依存なしで簡単にテスト可能 - 柔軟性: 実装の交換が容易

プロジェクトのニーズに基づいて選択的にClean Architectureを適用してください。目標は、パターンへの教条的な固執ではなく、保守可能でテスト可能なコードです。