クリーンアーキテクチャーをフロントエンドに適用する

クリーンアーキテクチャーをフロントエンドに活用した場合の具体的なコードや、組み込み方について解説します。

公開: 更新: 著者: Prod. Ready 編集部

はじめに

この記事では、TypeScript と React で作るフロントエンドにクリーンアーキテクチャの考え方を適用する方法を、タスク管理機能を題材に実装コード付きで解説します。レイヤーの分け方、依存の向き、ディレクトリ構成、Entity・UseCase・Repository・DI・カスタムフックの具体的な実装、そしてテストの書き方までを一通りカバーします。

対象読者は次のような方です。

• React で画面は作れるが、コンポーネントが肥大化して保守しづらくなった経験がある

• useEffect の中に fetch とビジネスロジックが混ざっていて、テストの書き方が分からない

• 「クリーンアーキテクチャ」という言葉は知っているが、フロントエンドでの具体的な形がイメージできない

バックエンドの文脈で語られがちなクリーンアーキテクチャですが、核心は「依存の向きを制御する」というシンプルな原則であり、フロントエンドにもそのまま活かせます。

なぜフロントエンドにクリーンアーキテクチャなのか

まず、素朴に React アプリを作ったときに起きがちな問題を整理します。

• コンポーネント内に fetch が直書きされ、API の URL やレスポンス形式の変更が UI 全体に波及する

• API レスポンスの型(snake_case の DTO)がそのまま props として末端コンポーネントまで流れていく

• 「完了済みタスクは再完了できない」のような業務ルールが、複数のコンポーネントの onClick に重複して書かれる

• ロジックが React に絡みついているため、ブラウザとレンダリング抜きの単体テストが書けない

クリーンアーキテクチャはこれらを「依存の向きの制御」で解決します。

UI・通信・状態管理ライブラリといった変わりやすいものを外側に、業務ルールという変わりにくいものを内側に置き、依存を常に内向きに限定する。これにより、ロジックは React にも fetch にも依存しない純粋な TypeScript になり、単体テストが書け、API やライブラリの変更の影響範囲が外側の層に閉じます。

ただし、どんなプロジェクトにも導入すべきものではありません。レイヤー分割はコード量と学習コストを確実に増やします。目安として、次の 2つ以上 に当てはまるなら導入する価値があります。

①運用期間が半年を超える見込みで、画面数が 10 を超える

②業務ルール(バリデーション・状態遷移・計算)がフロント側に一定量ある

③複数人で開発する、または将来引き継ぐ

④API が未確定、またはバックエンドの差し替え・モック開発が想定される

LPや社内向けの単純なCRUD画面だけなら、後述する「Repository だけ分離する」程度の薄い適用で十分です。

レイヤー設計:4つの層と依存のルール

本記事では次の4層 に分けます。重要なのは層の数ではなく、依存が常に内側(Domain)へ向かうという 1つのルールです。

上段3層は、依存が内向きに一方通行 で流れます。Infrastructure層だけは Application層が定義したポート(interface)を実装する形でぶら下がり、通信の詳細を外側に隔離します。

• Domain:Entityと値オブジェクト。業務ルールの置き場所で、他のどの層にも依存しない純粋なTypeScript

• Application:UseCase(アプリの操作単位)と、外部とやり取りするためのポート(interface)。Domain層にのみ依存

• Infrastructure:ポートの実装。HTTPクライアント、Repository実装、localStorageアダプタなど

• Presentation:Reactのファイル。コンポーネントとカスタムフックは、UseCaseを呼び出すだけで、通信の詳細を知らない

ポイントは、Application層から Infrastructure層への依存が存在しないことです。UseCaseは、TaskRepositoryという interfaceしか知らず、その実体が HTTPなのか、localStorageなのかを知りません。「実装の詳細をinterfaceに依存させる」この反転が依存性逆転の原則(DIP)で、クリーンアーキテクチャの核です。

ディレクトリ構成

以降のコードは、Vite + React + TypeScript のプロジェクトで次の構成を前提にします。層がそのままフォルダになるので、コードの置き場所に迷いません。

src/
├── domain/
│   ├── task.ts                      # Entity・値オブジェクト
│   └── task.test.ts
├── application/
│   ├── ports/
│   │   └── taskRepository.ts        # ポート(interface)
│   └── usecases/
│       ├── getTasks.ts
│       └── completeTask.ts
├── infrastructure/
│   └── repositories/
│       └── httpTaskRepository.ts    # ポートの実装
├── presentation/
│   ├── hooks/
│   │   └── useTasks.ts
│   └── components/
│       ├── TaskList.tsx
│       └── TaskItem.tsx
└── di/
    └── container.tsx                # 依存の組み立て(Composition Root)

機能が増えてきたら src/features/task/ 配下に同じ 4層を切る「機能別 × レイヤー」構成に発展させるのが定石です。まずは、1機能でこの形を体に入れることをおすすめします。

実装①:Entity(ドメインモデル)

最内層の Domain から書きます。Entityは「データ+そのデータに関する業務ルール」をまとめたもので、Reactにも、fetch にも依存しない純粋なTypeScriptです。

// src/domain/task.ts
// Branded Typeで、「ただの string」と区別する

export type TaskId = string & { readonly __brand: 'TaskId' };

export const TaskId = (value: string): TaskId => {
  if (value.length === 0) throw new Error('TaskId が空です');
  return value as TaskId;
};

export type TaskStatus = 'todo' | 'done';

interface TaskProps {
  id: TaskId;
  title: string;
  status: TaskStatus;
  createdAt: Date;
}

export class Task {
  private constructor(private readonly props: TaskProps) {}

  /** 新規作成:バリデーションを通す入口 */
  static create(input: { id: string; title: string }): Task {
    const title = input.title.trim();
    if (title.length === 0 || title.length > 100) {
      throw new Error('タイトルは1〜100文字で入力してください');
    }
    return new Task({
      id: TaskId(input.id),
      title,
      status: 'todo',
      createdAt: new Date(),
    });
  }

  /** 保存済みデータからの復元:バリデーション済みとみなす */
  static reconstruct(props: TaskProps): Task {
    return new Task(props);
  }

  /** 業務ルール:完了済みタスクは再完了できない */
  complete(): Task {
    if (this.props.status === 'done') {
      throw new Error('すでに完了済みのタスクです');
    }
    return new Task({ ...this.props, status: 'done' });
  }

  get id(): TaskId {
    return this.props.id;
  }
  get title(): string {
    return this.props.title;
  }
  get isDone(): boolean {
    return this.props.status === 'done';
  }
}

設計上のポイントは、3つです。

①イミュータブル:complete() は自分を書き換えず新しいTaskを返します。Reactのstate更新は参照の変化で検知されるため、イミュータブルなEntityは、Reactと非常に相性が良いです。

②createとreconstructを分ける:新規作成時はバリデーションを通し、APIから取得した保存済みデータの復元はバリデーションを通さない、という入口の区別を型で表現します。

③Branded Type: TaskIdとUserIdのような「中身はどちらもstring」の取り違えをコンパイルエラーにできます。

実装②:UseCaseとポート(interface)

Application層には「アプリができる操作」を、1クラス・1操作で置きます。その前に、外部とのやり取りを抽象化するポートを定義します。

// src/application/ports/taskRepository.ts
import { Task, TaskId } from '../../domain/task';

export interface TaskRepository {
  findAll(): Promise<Task[]>;
  findById(id: TaskId): Promise<Task | null>;
  save(task: Task): Promise<void>;
}

戻り値がDTOではなく、DomainのTaskである点が重要です。「永続化の都合」はこのinterfaceより外側に閉じ込めます。

// src/application/usecases/getTasks.ts
import { Task } from '../../domain/task';
import { TaskRepository } from '../ports/taskRepository';

export class GetTasksUseCase {
  constructor(private readonly taskRepository: TaskRepository) {}

  async execute(): Promise<Task[]> {
    return this.taskRepository.findAll();
  }
}
// src/application/usecases/completeTask.ts
import { TaskId } from '../../domain/task';
import { TaskRepository } from '../ports/taskRepository';

export class CompleteTaskUseCase {
  constructor(private readonly taskRepository: TaskRepository) {}

  async execute(id: string): Promise<void> {
    const taskId = TaskId(id);
    const task = await this.taskRepository.findById(taskId);
    if (task === null) {
      throw new Error('タスクが見つかりません');
    }
    const completed = task.complete(); // 業務ルールは、Entityに委譲
    await this.taskRepository.save(completed);
  }
}

UseCaseは、「取得 → ドメインロジック実行 → 保存」という手順の調整役に徹し、業務ルールそのもの(再完了の禁止)は Entityに委譲しています。コンストラクタでTaskRepositoryを受け取っているので、テスト時にはインメモリ実装を差し込めます。

実装③:Repository実装(APIアダプタ)

Infrastructure層でポートを実装します。HTTP通信、APIのURL、レスポンスの形(DTO)といった「外側の都合」はすべてこのファイルに閉じ込めます。

// src/infrastructure/repositories/httpTaskRepository.ts
import { Task, TaskId, TaskStatus } from '../../domain/task';
import { TaskRepository } from '../../application/ports/taskRepository';

// API レスポンスの形。snake_caseはここより内側に持ち込まない
interface TaskDto {
  id: string;
  title: string;
  status: TaskStatus;
  created_at: string;
}

const toDomain = (dto: TaskDto): Task =>
  Task.reconstruct({
    id: TaskId(dto.id),
    title: dto.title,
    status: dto.status,
    createdAt: new Date(dto.created_at),
  });

export class HttpTaskRepository implements TaskRepository {
  constructor(private readonly baseUrl: string) {}

  async findAll(): Promise<Task[]> {
    const res = await fetch(`${this.baseUrl}/tasks`);
    if (!res.ok) throw new Error(`タスクの取得に失敗しました(${res.status})`);
    const dtos: TaskDto[] = await res.json();
    return dtos.map(toDomain);
  }

  async findById(id: TaskId): Promise<Task | null> {
    const res = await fetch(`${this.baseUrl}/tasks/${id}`);
    if (res.status === 404) return null;
    if (!res.ok) throw new Error(`タスクの取得に失敗しました(${res.status})`);
    const dto: TaskDto = await res.json();
    return toDomain(dto);
  }

  async save(task: Task): Promise<void> {
    const res = await fetch(`${this.baseUrl}/tasks/${task.id}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        title: task.title,
        status: task.isDone ? 'done' : 'todo',
      }),
    });
    if (!res.ok) throw new Error(`タスクの保存に失敗しました(${res.status})`);
  }
}

DTO ⇔ Domain の変換(toDomain)がこの層の主な仕事です。APIのフィールド名が変わっても、修正はこのファイルだけで済みます。fetchをaxiosやkyに替える、バックエンド完成前 LocalStorageTaskRepositoryで動かす、といった差し替えも interfaceを満たす限り他の層に影響しません。

実装④:DI(依存の組み立て)とReactへの接続

「どのUseCaseにどのRepository実装を渡すか」の組み立ては、1箇所(Composition Root)に集約し、React Context で配ります。DIコンテナライブラリは必須ではなく、この規模なら手組みで十分です。

// src/di/container.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
import { HttpTaskRepository } from '../infrastructure/repositories/httpTaskRepository';
import { GetTasksUseCase } from '../application/usecases/getTasks';
import { CompleteTaskUseCase } from '../application/usecases/completeTask';

const createContainer = () => {
  const taskRepository = new HttpTaskRepository(import.meta.env.VITE_API_URL);
  return {
    getTasksUseCase: new GetTasksUseCase(taskRepository),
    completeTaskUseCase: new CompleteTaskUseCase(taskRepository),
  };
};

export type Container = ReturnType<typeof createContainer>;

const ContainerContext = createContext<Container | null>(null);

export const ContainerProvider = ({
  children,
  container,
}: {
  children: ReactNode;
  container?: Container; // テスト時に差し替え可能
}) => {
  const [value] = useState(() => container ?? createContainer());
  return (
    <ContainerContext.Provider value={value}>
      {children}
    </ContainerContext.Provider>
  );
};

export const useContainer = (): Container => {
  const container = useContext(ContainerContext);
  if (container === null) {
    throw new Error('ContainerProvider の内側で使用してください');
  }
  return container;
};

アプリのエントリポイントで全体を包みます。

// src/main.tsx
createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <ContainerProvider>
      <App />
    </ContainerProvider>
  </StrictMode>,
);

newを書く場所がここだけになるため、依存関係の全体像が1ファイルで見渡せます。containerをpropsで差し替えられるようにしておくと、Storybookやテストでインメモリ実装に丸ごと入れ替えられます。

実装⑤:カスタムフックとUIコンポーネント

Presentation層では、UseCaseの呼び出しとReactの状態管理をカスタムフックにまとめ、コンポーネントは表示に専念させます。

// src/presentation/hooks/useTasks.ts
import { useCallback, useEffect, useState } from 'react';
import { Task } from '../../domain/task';
import { useContainer } from '../../di/container';

export const useTasks = () => {
  const { getTasksUseCase, completeTaskUseCase } = useContainer();
  const [tasks, setTasks] = useState<Task[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  const reload = useCallback(async () => {
    setLoading(true);
    try {
      setTasks(await getTasksUseCase.execute());
      setError(null);
    } catch (e) {
      setError(e instanceof Error ? e.message : '不明なエラーが発生しました');
    } finally {
      setLoading(false);
    }
  }, [getTasksUseCase]);

  useEffect(() => {
    void reload();
  }, [reload]);

  const complete = useCallback(
    async (id: string) => {
      try {
        await completeTaskUseCase.execute(id);
        await reload();
      } catch (e) {
        setError(e instanceof Error ? e.message : '不明なエラーが発生しました');
      }
    },
    [completeTaskUseCase, reload],
  );

  return { tasks, loading, error, complete };
};
// src/presentation/components/TaskList.tsx
import { useTasks } from '../hooks/useTasks';

export const TaskList = () => {
  const { tasks, loading, error, complete } = useTasks();

  if (loading) return <p>読み込み中...</p>;
  if (error) return <p role="alert">{error}</p>;

  return (
    <ul>
      {tasks.map((task) => (
        <li key={task.id}>
          <span>{task.isDone ? `✓ ${task.title}` : task.title}</span>
          {!task.isDone && (
            <button onClick={() => void complete(task.id)}>完了</button>
          )}
        </li>
      ))}
    </ul>
  );
};

コンポーネントが知っているのは「useTasksが返す値」だけで、fetchもURLもDTOも登場しません。なお、実務ではフック内の useState + useEffectを、TanStack Queryに置き換えるのがおすすめです。useQuery({ queryKey: ['tasks'], queryFn: () => getTasksUseCase.execute() }) のように queryFnからUseCase を呼べば、キャッシュや再取得はライブラリに任せつつレイヤー構造はそのまま保てます。

テスト戦略:層ごとに書き分ける

この構成の最大の見返りがテストです。層ごとにテストの性質が変わります。 Domain:モック不要の純粋な単体テスト。 一番数を厚くする場所です。

// src/domain/task.test.ts
import { describe, expect, test } from 'vitest';
import { Task } from './task';

describe('Task', () => {
  test('空のタイトルでは作成できない', () => {
    expect(() => Task.create({ id: '1', title: '  ' })).toThrow();
  });

  test('完了済みタスクは再完了できない', () => {
    const done = Task.create({ id: '1', title: '記事を書く' }).complete();
    expect(() => done.complete()).toThrow('すでに完了済みのタスクです');
  });
});

Application:インメモリRepositoryを差し込んでテスト。 fetchのモックは不要です。

// src/application/usecases/completeTask.test.ts
import { expect, test } from 'vitest';
import { Task, TaskId } from '../../domain/task';
import { TaskRepository } from '../ports/taskRepository';
import { CompleteTaskUseCase } from './completeTask';

class InMemoryTaskRepository implements TaskRepository {
  constructor(private tasks: Task[] = []) {}
  async findAll() {
    return [...this.tasks];
  }
  async findById(id: TaskId) {
    return this.tasks.find((t) => t.id === id) ?? null;
  }
  async save(task: Task) {
    this.tasks = [...this.tasks.filter((t) => t.id !== task.id), task];
  }
}

test('タスクを完了して保存する', async () => {
  const repo = new InMemoryTaskRepository([
    Task.create({ id: '1', title: '記事を書く' }),
  ]);
  const usecase = new CompleteTaskUseCase(repo);

  await usecase.execute('1');

  const saved = await repo.findById(TaskId('1'));
  expect(saved?.isDone).toBe(true);
});

Presentation:テスト用コンテナを注入してコンポーネントテスト。 ContainerProviderのcontainer propsに、InMemoryTaskRepositoryで組んだコンテナを渡してTesting Libraryで描画します。ここは「表示とインタラクション」の確認に絞り、業務ルールの網羅は、Domain / Application のテストに任せるのが効率的です。

Infrastructure:優先度は低め。 toDomainの変換ロジックだけ単体テストし、通信自体はMSWを使った結合テストかE2E でカバーすれば十分なことが多いです。

よくある失敗とアンチパターン

導入現場でよく見るつまずきを挙げます。

• 過剰な抽象化:単純な一覧表示にまでフル装備を適用し、execute()がRepositoryを1行呼ぶだけの「素通し UseCase」が量産される。素通し自体は将来の変更点の置き場として許容できますが、層を増やす痛みに見合う複雑さがその機能にあるかを毎回問うべきです。

• DTOの漏れ:created_at(snake_case)がコンポーネントのpropsに現れたら、変換がInfrastructureに閉じていないサインです。レビューで機械的にチェックできます。

• ドメインが貧相:Entityがgetterだけの入れ物になり、業務ルールがUseCaseやコンポーネントに散らばる状態。「このルールはどのデータに属するか」を考え、データと同じ場所にロジックを置きます。

• DomainへのReact混入:Entityからreactをimportした時点でレイヤーは崩壊します。ESLintのimport/no-restricted-pathsで「domain は何も import できない」「applicationは、domainのみ」といった依存方向をルール化し、人間の注意力に頼らないのがおすすめです。

• DIライブラリへの早すぎる投入:InversifyJSなどはデコレータやreflect-metadataの設定コストが先に来ます。まずは本記事のような手組みのComposition Rootで始め、依存が数十を超えてから検討で十分です。

まとめ:一気にやらず、段階的に導入する

本記事の要点は、1つに集約できます。変わりやすいもの(UI・通信・ライブラリ)を外側に、変わりにくいもの(業務ルール)を内側に置き、依存を内向きに限定する。 Entity・UseCase・ポート・DI はすべてこのルールを実現する道具です。

既存プロジェクトに入れる場合は、全層を一度に作らず次の順で進めるのが現実的です。

①Repositoryの分離:コンポーネント内のfetchを、Repositoryクラスに集め、DTO → ドメイン型の変換を始める(効果が最も大きい一歩)

②UseCaseの抽出:複数コンポーネントで重複している操作手順を、UseCaseに移す

③Entityの充実:バリデーションや状態遷移のルールをEntityに寄せ、単体テストを書く

④依存方向の機械化:ESLintルールで層の境界を守る

ステップ1だけでも「API変更に強く、テストが書ける」状態に大きく近づきます。まずは手元のプロジェクトの1機能で試してみてください。