T-CREATOR

React クリーンアーキテクチャ実践:UI・アプリ・ドメイン・データの責務分離

React クリーンアーキテクチャ実践:UI・アプリ・ドメイン・データの責務分離

React アプリケーションの開発において、コードの保守性や拡張性を高めるためには、適切なアーキテクチャ設計が欠かせません。特に、UI・アプリケーション・ドメイン・データといった各層の責務を明確に分離することで、テストしやすく変更に強いコードベースを構築できます。本記事では、React におけるクリーンアーキテクチャの実践方法を、具体的なコード例とともに詳しく解説します。

背景

React アプリケーションの開発では、コンポーネントにビジネスロジックやデータ取得処理を直接記述してしまうケースが多く見られます。開発初期は問題なく進みますが、アプリケーションが成長するにつれて、コードの見通しが悪くなり、テストや機能追加が困難になっていきます。

クリーンアーキテクチャは、ソフトウェアの各関心事を層として分離し、依存関係を一方向に保つことで、変更に強く保守しやすいシステムを実現する設計思想です。React においてこの考え方を適用することで、UI の変更がビジネスロジックに影響を与えず、逆にビジネスロジックの変更が UI に影響を与えないような構造を作れます。

以下の図は、クリーンアーキテクチャにおける依存関係の流れを示しています。

mermaidflowchart TD
  ui["UI 層<br/>(Presentation)"]
  app["アプリケーション層<br/>(Use Cases)"]
  domain["ドメイン層<br/>(Entities / Business Rules)"]
  data["データ層<br/>(Infrastructure)"]

  ui -->|依存| app
  app -->|依存| domain
  data -->|実装| domain

  style domain fill:#e1f5ff
  style app fill:#fff4e1
  style ui fill:#ffe1f5
  style data fill:#e1ffe1

この図から分かるように、依存の方向は外側から内側へ向かい、ドメイン層が最も独立した状態を保ちます。

各層の役割

React のクリーンアーキテクチャでは、以下の 4 つの層に分離します。

#層名役割具体例
1UI 層ユーザーインターフェースの表示と操作React コンポーネント、Hooks
2アプリケーション層ユースケースの調整と実行カスタムフック、サービス
3ドメイン層ビジネスロジックとルールエンティティ、バリデーション
4データ層外部システムとの通信API クライアント、Repository

課題

従来の React 開発では、以下のような課題が頻繁に発生します。

コンポーネントの肥大化

コンポーネント内にビジネスロジック、状態管理、API 呼び出しをすべて記述すると、1 つのファイルが数百行に及ぶことがあります。これにより、コードの理解が困難になり、バグの温床となります。

テストの困難さ

UI とロジックが密結合していると、ビジネスロジックのテストのために React コンポーネントをマウントする必要があり、テストが複雑で遅くなります。本来、ビジネスロジックは UI から独立してテストできるべきです。

変更の影響範囲の拡大

API のレスポンス形式が変わったとき、その影響がコンポーネント全体に波及します。また、UI フレームワークを変更する際にも、ビジネスロジックを書き直す必要が生じてしまいます。

以下の図は、責務分離がない場合の問題点を示しています。

mermaidflowchart LR
  component["React Component<br/>(UI + Logic + API)"]
  api["API"]

  component -->|直接呼び出し| api
  api -->|レスポンス| component

  change["API 変更"] -.->|影響大| component
  ui_change["UI 変更"] -.->|影響大| component

  style component fill:#ffcccc
  style change fill:#ff9999
  style ui_change fill:#ff9999

このように、すべての変更がコンポーネントに影響を与える構造になっています。

再利用性の低下

ビジネスロジックがコンポーネントに埋め込まれていると、同じロジックを別の場所で使いたいときに、コードの重複が発生します。DRY(Don't Repeat Yourself)原則に反する状態になりがちです。

解決策

クリーンアーキテクチャの原則を React に適用することで、これらの課題を解決できます。重要なのは、各層の責務を明確にし、依存関係を一方向に保つことです。

依存性逆転の原則

クリーンアーキテクチャの核心は、依存性逆転の原則(DIP: Dependency Inversion Principle)にあります。上位の層(ドメイン層)が下位の層(データ層)に依存しないよう、インターフェース(TypeScript では型やクラス)を使って依存関係を逆転させます。

以下の図は、依存性逆転の仕組みを示しています。

mermaidflowchart TB
  subgraph domain_layer["ドメイン層"]
    use_case["UseCase"]
    repository_interface["Repository Interface"]
  end

  subgraph data_layer["データ層"]
    repository_impl["Repository 実装"]
    api["API Client"]
  end

  use_case -->|依存| repository_interface
  repository_impl -->|実装| repository_interface
  repository_impl -->|使用| api

  style repository_interface fill:#e1f5ff
  style use_case fill:#fff4e1
  style repository_impl fill:#e1ffe1

UseCase は具体的な実装ではなく、インターフェースに依存します。これにより、データ層の変更がドメイン層に影響を与えません。

層ごとのディレクトリ構成

プロジェクトのディレクトリ構造を層ごとに分けることで、責務の分離を物理的にも明確にします。

bashsrc/
├── presentation/        # UI 層
│   ├── components/
│   └── hooks/
├── application/         # アプリケーション層
│   └── usecases/
├── domain/             # ドメイン層
│   ├── entities/
│   ├── repositories/   # インターフェース
│   └── services/
└── infrastructure/     # データ層
    ├── api/
    └── repositories/   # 実装

このディレクトリ構成により、各層の役割が一目で分かり、チーム開発でも混乱が少なくなります。

レイヤー間の通信ルール

各層は、以下のルールで通信します。

#ルール説明
1外から内への依存のみUI 層はアプリケーション層に、アプリケーション層はドメイン層に依存する
2インターフェースでの抽象化ドメイン層はインターフェースのみを公開し、実装の詳細を隠蔽する
3データの変換データ層で取得した外部データは、ドメインエンティティに変換してから渡す

具体例

実際のコード例を通じて、各層の実装方法を見ていきましょう。ユーザー管理機能を例に、クリーンアーキテクチャを実践します。

ドメイン層の実装

まず、ビジネスロジックの中心となるドメイン層から実装します。ドメイン層は、他のどの層にも依存しない独立した存在です。

エンティティの定義

エンティティは、ビジネスの核となるデータ構造とルールを表現します。

typescript// src/domain/entities/User.ts

/**
 * ユーザーエンティティ
 * ビジネスルールとデータ構造を定義
 */
export class User {
  constructor(
    public readonly id: string,
    public readonly name: string,
    public readonly email: string,
    public readonly createdAt: Date
  ) {
    // バリデーションはエンティティ内で実行
    this.validate();
  }

  /**
   * ユーザー情報のバリデーション
   */
  private validate(): void {
    if (!this.name || this.name.trim().length === 0) {
      throw new Error('User name cannot be empty');
    }

    if (!this.isValidEmail(this.email)) {
      throw new Error('Invalid email format');
    }
  }

このコードでは、User エンティティを定義し、コンストラクタ内でバリデーションを実行しています。

バリデーションロジック

バリデーションはビジネスルールの一部として、エンティティに含めます。

typescript  /**
   * メールアドレスの形式チェック
   */
  private isValidEmail(email: string): boolean {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return emailRegex.test(email);
  }

  /**
   * ユーザー名を変更する
   * ビジネスルールを適用した安全な変更メソッド
   */
  public changeName(newName: string): User {
    // イミュータブルな更新
    return new User(
      this.id,
      newName,
      this.email,
      this.createdAt
    );
  }
}

エンティティはイミュータブル(不変)に保ち、変更時は新しいインスタンスを返します。

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

リポジトリのインターフェースをドメイン層で定義します。実装の詳細は隠蔽されています。

typescript// src/domain/repositories/UserRepository.ts

import { User } from '../entities/User';

/**
 * ユーザーリポジトリのインターフェース
 * ドメイン層で定義し、データ層で実装する
 */
export interface UserRepository {
  /**
   * ユーザー ID でユーザーを取得
   */
  findById(id: string): Promise<User | null>;

  /**
   * すべてのユーザーを取得
   */
  findAll(): Promise<User[]>;

  /**
   * ユーザーを保存
   */
  save(user: User): Promise<void>;

  /**
   * ユーザーを削除
   */
  delete(id: string): Promise<void>;
}

このインターフェースにより、ドメイン層はデータの取得方法の詳細を知る必要がありません。

データ層の実装

次に、外部システムとの通信を担当するデータ層を実装します。

API クライアント

API との通信を担当するクライアントを作成します。

typescript// src/infrastructure/api/UserApiClient.ts

/**
 * API レスポンスの型定義
 */
export interface UserResponse {
  id: string;
  name: string;
  email: string;
  created_at: string; // API はスネークケース
}

/**
 * ユーザー API クライアント
 * 外部 API との通信を担当
 */
export class UserApiClient {
  private baseUrl: string;

  constructor(baseUrl: string = '/api') {
    this.baseUrl = baseUrl;
  }

  /**
   * ユーザー一覧を取得
   */
  async fetchUsers(): Promise<UserResponse[]> {
    const response = await fetch(`${this.baseUrl}/users`);

    if (!response.ok) {
      throw new Error(`HTTP Error: ${response.status}`);
    }

    return response.json();
  }

API クライアントは、HTTP 通信の詳細を隠蔽し、型安全なインターフェースを提供します。

個別ユーザー取得

ID を指定してユーザーを取得するメソッドです。

typescript  /**
   * ID でユーザーを取得
   */
  async fetchUserById(id: string): Promise<UserResponse | null> {
    const response = await fetch(`${this.baseUrl}/users/${id}`);

    if (response.status === 404) {
      return null;
    }

    if (!response.ok) {
      throw new Error(`HTTP Error: ${response.status}`);
    }

    return response.json();
  }
}

404 エラーは異常ではなく、ユーザーが存在しない正常なケースとして扱います。

リポジトリの実装

ドメイン層で定義したインターフェースを、データ層で実装します。

typescript// src/infrastructure/repositories/UserRepositoryImpl.ts

import { User } from '../../domain/entities/User';
import { UserRepository } from '../../domain/repositories/UserRepository';
import { UserApiClient, UserResponse } from '../api/UserApiClient';

/**
 * ユーザーリポジトリの実装
 * API レスポンスをドメインエンティティに変換
 */
export class UserRepositoryImpl implements UserRepository {
  constructor(private apiClient: UserApiClient) {}

  /**
   * API レスポンスをエンティティに変換
   */
  private toEntity(response: UserResponse): User {
    return new User(
      response.id,
      response.name,
      response.email,
      new Date(response.created_at)
    );
  }

このメソッドは、API のデータ構造をドメインエンティティに変換する責務を持ちます。

データ取得メソッドの実装

インターフェースで定義したメソッドを実装します。

typescript  /**
   * ID でユーザーを取得
   */
  async findById(id: string): Promise<User | null> {
    const response = await this.apiClient.fetchUserById(id);

    if (!response) {
      return null;
    }

    return this.toEntity(response);
  }

  /**
   * すべてのユーザーを取得
   */
  async findAll(): Promise<User[]> {
    const responses = await this.apiClient.fetchUsers();
    return responses.map(response => this.toEntity(response));
  }

  /**
   * ユーザーを保存(実装例)
   */
  async save(user: User): Promise<void> {
    // 実装は省略
    console.log('Saving user:', user);
  }

  /**
   * ユーザーを削除(実装例)
   */
  async delete(id: string): Promise<void> {
    // 実装は省略
    console.log('Deleting user:', id);
  }
}

リポジトリは、API クライアントを使ってデータを取得し、エンティティに変換して返します。

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

アプリケーション層では、ユースケース(ユーザーの操作シナリオ)を実装します。

ユースケースの定義

ユーザー一覧を取得するユースケースを作成します。

typescript// src/application/usecases/GetAllUsersUseCase.ts

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

/**
 * ユーザー一覧取得ユースケース
 * ビジネスルールの調整と実行を担当
 */
export class GetAllUsersUseCase {
  constructor(private userRepository: UserRepository) {}

  /**
   * すべてのユーザーを取得して返す
   */
  async execute(): Promise<User[]> {
    try {
      const users = await this.userRepository.findAll();

      // 必要に応じて、ソートやフィルタリングなどの
      // アプリケーション固有のロジックを追加
      return users.sort(
        (a, b) =>
          a.createdAt.getTime() - b.createdAt.getTime()
      );
    } catch (error) {
      // エラーハンドリングもユースケースの責務
      console.error('Error fetching users:', error);
      throw new Error('Failed to fetch users');
    }
  }
}

ユースケースは、リポジトリを使ってデータを取得し、必要に応じて加工します。

個別ユーザー取得ユースケース

特定のユーザーを取得するユースケースです。

typescript// src/application/usecases/GetUserByIdUseCase.ts

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

/**
 * ID によるユーザー取得ユースケース
 */
export class GetUserByIdUseCase {
  constructor(private userRepository: UserRepository) {}

  /**
   * 指定された ID のユーザーを取得
   */
  async execute(id: string): Promise<User> {
    // 入力値のバリデーション
    if (!id || id.trim().length === 0) {
      throw new Error('User ID is required');
    }

    const user = await this.userRepository.findById(id);

    if (!user) {
      throw new Error(`User not found: ${id}`);
    }

    return user;
  }
}

ユースケースでは、入力値の検証やエラーハンドリングも行います。

UI 層の実装

最後に、React コンポーネントとカスタムフックで UI 層を実装します。

依存性注入の設定

各層のインスタンスを生成し、依存関係を組み立てます。

typescript// src/presentation/di/container.ts

import { UserApiClient } from '../../infrastructure/api/UserApiClient';
import { UserRepositoryImpl } from '../../infrastructure/repositories/UserRepositoryImpl';
import { GetAllUsersUseCase } from '../../application/usecases/GetAllUsersUseCase';
import { GetUserByIdUseCase } from '../../application/usecases/GetUserByIdUseCase';

/**
 * 依存性注入コンテナ
 * アプリケーション全体の依存関係を組み立て
 */
class DIContainer {
  // インフラストラクチャ層
  private userApiClient = new UserApiClient();
  private userRepository = new UserRepositoryImpl(
    this.userApiClient
  );

  // アプリケーション層
  public getAllUsersUseCase = new GetAllUsersUseCase(
    this.userRepository
  );
  public getUserByIdUseCase = new GetUserByIdUseCase(
    this.userRepository
  );
}

export const diContainer = new DIContainer();

DI コンテナにより、依存関係の組み立てを一箇所に集約できます。

カスタムフックの作成

ユースケースを React から使いやすくするためのカスタムフックを作成します。

typescript// src/presentation/hooks/useUsers.ts

import { useState, useEffect } from 'react';
import { User } from '../../domain/entities/User';
import { diContainer } from '../di/container';

/**
 * ユーザー一覧を取得するカスタムフック
 */
export const useUsers = () => {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchUsers = async () => {
      try {
        setLoading(true);
        setError(null);

        // ユースケースを実行
        const result =
          await diContainer.getAllUsersUseCase.execute();
        setUsers(result);
      } catch (err) {
        setError(
          err instanceof Error
            ? err.message
            : 'Unknown error'
        );
      } finally {
        setLoading(false);
      }
    };

    fetchUsers();
  }, []);

  return { users, loading, error };
};

カスタムフックは、ユースケースの実行と状態管理を担当します。

React コンポーネント

最後に、カスタムフックを使った React コンポーネントを作成します。

typescript// src/presentation/components/UserList.tsx

import React from 'react';
import { useUsers } from '../hooks/useUsers';

/**
 * ユーザー一覧表示コンポーネント
 * UI の表示のみに集中
 */
export const UserList: React.FC = () => {
  const { users, loading, error } = useUsers();

  if (loading) {
    return <div>読み込み中...</div>;
  }

  if (error) {
    return <div>エラー: {error}</div>;
  }

  return (
    <div>
      <h2>ユーザー一覧</h2>
      <ul>
        {users.map((user) => (
          <li key={user.id}>
            <strong>{user.name}</strong> ({user.email})
          </li>
        ))}
      </ul>
    </div>
  );
};

コンポーネントは、ビジネスロジックを持たず、UI の表示のみに専念します。

アーキテクチャ全体の流れ

以下の図は、実装した各層がどのように連携するかを示しています。

mermaidsequenceDiagram
  participant UI as UserList<br/>Component
  participant Hook as useUsers<br/>Hook
  participant UC as GetAllUsers<br/>UseCase
  participant Repo as UserRepository<br/>Impl
  participant API as UserApi<br/>Client

  UI->>Hook: レンダリング
  Hook->>UC: execute()
  UC->>Repo: findAll()
  Repo->>API: fetchUsers()
  API-->>Repo: UserResponse[]
  Repo->>Repo: toEntity()
  Repo-->>UC: User[]
  UC->>UC: sort()
  UC-->>Hook: User[]
  Hook->>Hook: setState()
  Hook-->>UI: 再レンダリング

このシーケンス図から、各層の責務が明確に分離され、依存関係が一方向であることが分かります。

テストの実装例

クリーンアーキテクチャの大きな利点は、テストのしやすさです。各層を独立してテストできます。

ドメイン層のテスト

エンティティのテストは、外部依存なしで実行できます。

typescript// src/domain/entities/__tests__/User.test.ts

import { User } from '../User';

describe('User Entity', () => {
  it('正しい値でユーザーを作成できる', () => {
    const user = new User(
      '1',
      'John Doe',
      'john@example.com',
      new Date()
    );

    expect(user.name).toBe('John Doe');
    expect(user.email).toBe('john@example.com');
  });

  it('無効なメールアドレスでエラーをスローする', () => {
    expect(() => {
      new User(
        '1',
        'John Doe',
        'invalid-email',
        new Date()
      );
    }).toThrow('Invalid email format');
  });

  it('空の名前でエラーをスローする', () => {
    expect(() => {
      new User('1', '', 'john@example.com', new Date());
    }).toThrow('User name cannot be empty');
  });
});

ドメインロジックのテストは、純粋な関数のテストとして高速に実行できます。

ユースケースのテスト

ユースケースは、モックリポジトリを使ってテストします。

typescript// src/application/usecases/__tests__/GetAllUsersUseCase.test.ts

import { GetAllUsersUseCase } from '../GetAllUsersUseCase';
import { UserRepository } from '../../../domain/repositories/UserRepository';
import { User } from '../../../domain/entities/User';

// モックリポジトリの作成
const mockRepository: UserRepository = {
  findAll: jest.fn(),
  findById: jest.fn(),
  save: jest.fn(),
  delete: jest.fn(),
};

describe('GetAllUsersUseCase', () => {
  it('ユーザー一覧を作成日順に取得できる', async () => {
    const users = [
      new User(
        '2',
        'Bob',
        'bob@example.com',
        new Date('2024-02-01')
      ),
      new User(
        '1',
        'Alice',
        'alice@example.com',
        new Date('2024-01-01')
      ),
    ];

    (mockRepository.findAll as jest.Mock).mockResolvedValue(
      users
    );

    const useCase = new GetAllUsersUseCase(mockRepository);
    const result = await useCase.execute();

    // 作成日順にソートされているか確認
    expect(result[0].id).toBe('1');
    expect(result[1].id).toBe('2');
  });
});

モックを使うことで、データ層に依存せずにユースケースのロジックをテストできます。

UI 層のテスト

React コンポーネントは、React Testing Library でテストします。

typescript// src/presentation/components/__tests__/UserList.test.tsx

import {
  render,
  screen,
  waitFor,
} from '@testing-library/react';
import { UserList } from '../UserList';
import { useUsers } from '../../hooks/useUsers';

// カスタムフックをモック
jest.mock('../../hooks/useUsers');

const mockUseUsers = useUsers as jest.MockedFunction<
  typeof useUsers
>;

describe('UserList Component', () => {
  it('読み込み中は「読み込み中...」を表示する', () => {
    mockUseUsers.mockReturnValue({
      users: [],
      loading: true,
      error: null,
    });

    render(<UserList />);
    expect(
      screen.getByText('読み込み中...')
    ).toBeInTheDocument();
  });

  it('ユーザー一覧を表示する', async () => {
    const mockUsers = [
      {
        id: '1',
        name: 'Alice',
        email: 'alice@example.com',
        createdAt: new Date(),
      },
      {
        id: '2',
        name: 'Bob',
        email: 'bob@example.com',
        createdAt: new Date(),
      },
    ] as any;

    mockUseUsers.mockReturnValue({
      users: mockUsers,
      loading: false,
      error: null,
    });

    render(<UserList />);

    await waitFor(() => {
      expect(screen.getByText('Alice')).toBeInTheDocument();
      expect(screen.getByText('Bob')).toBeInTheDocument();
    });
  });
});

UI のテストでは、ビジネスロジックではなく、表示とユーザー操作に集中できます。

まとめ

React におけるクリーンアーキテクチャの実践方法を、具体的なコード例とともに解説しました。UI・アプリケーション・ドメイン・データの各層を明確に分離することで、以下のメリットが得られます。

まず、テストしやすいコードベースになります。各層を独立してテストでき、モックを使った高速なユニットテストが可能です。次に、変更に強い設計が実現できます。UI フレームワークや API の変更が、ビジネスロジックに影響を与えません。

また、再利用性が向上します。ビジネスロジックが UI から独立しているため、別のコンポーネントや別のプラットフォームでも再利用できます。さらに、チーム開発がスムーズになります。各層の責務が明確なため、複数人での並行開発が容易です。

最初は層の分離が冗長に感じられるかもしれませんが、アプリケーションが成長するにつれて、その価値が実感できるでしょう。小規模なプロジェクトでも、将来の拡張を見据えてクリーンアーキテクチャを採用する価値は十分にあります。

ぜひ、次のプロジェクトでクリーンアーキテクチャを実践し、保守性の高い React アプリケーションを構築してください。

関連リンク