🏰

フロントエンドアーキテクチャ設計 〜Clean Architecture実践パターン〜

に公開

はじめに

昨今フロントエンド開発が複雑化する中で、サーバサイドの開発でよく用いられる「クリーンアーキテクチャ」の原則をWebフロントエンドに適用する方法を考えていました。
本記事はクリーンアーキテクチャの原則をフロントエンドに応用した設計パターンについて紹介します。なお、実装例はReactで記述していますが、設計パターン自体は他のフレームワークでも応用可能です。

アーキテクチャの全体像と各レイヤーの責務

結果として以下のようなレイヤー構造を導き出しました。

clean architecture example with React

各レイヤーの責務は以下のとおりです。

  • Domain: アプリケーションの核となるモデルやビジネスルールを定義
  • UseCase: ユーザーが期待するアプリケーションの振る舞いを定義
  • Presenter: 外部システムとドメインの境界層
  • Adapters: 外部システムとのコミュニケーションを担当
  • View: ユーザーインターフェース。Reactコンポーネントなどが該当

重要な原則としては「依存の方向」になります。依存は常に外側から内側(例: Adapter → UseCase)に向かわなければならず、外側の変更が内側に影響してはいけないです。
図の左側はReactの実装詳細を示していますが、本記事では右側の同心円部分(Domain〜Adapters)に焦点を当てます。

また、以下のようなクリーンアーキテクチャとフロントエンド開発の間に存在する矛盾を踏まえ、View を同心円の外側に独立して配置しています。

  • クリーンアーキテクチャの原則では、UIは「詳細」であり、最外層に配置される
  • フロントエンド開発においてUIが中心的な関心事である

View を外側に配置することで、Reactコンポーネントから UseCase を実行できる一方、UseCase は呼び出し元が View(Reactコンポーネント)であることを知りません。この依存関係により、View の変更により内側のロジック層を書き直す必要がありません。
言い換えれば、同心円の領域はブラウザ/ReactのAPIに直接依存してはいけないということです。

実装例

clean architecture example with React

上図は各レイヤーの責務と依存関係を示しています。
ここからは、内側のDomain層から順に、各レイヤーの具体的な実装を見ていきます。例として、ユーザー一覧を取得する機能を実装しながら、各レイヤーがどのように連携するかを解説します。

Domain

domain/user.ts
import type { Result } from './result';

export interface UserRepository {
  getAll: () => Promise<Result<User[]>>;
}

export interface User {
  id: string;
  name: string;
  avatar: string;
}

アプリケーションの核となるモデル(User)を定義します。
usecase が「どのようにデータを取得するか(実装の詳細)」を知らずに済むよう、データ操作のためのインターフェース(UserRepository)を定義します。

UseCase

usecase/getUsers.ts
import type { User, UserRepository } from '../domain/user';

export const getUsers = async (repository: UserRepository): Promise<User[]> => {
  const result = await repository.getAll();
  if (result.ok) {
    return result.value;
  } else {
    throw new Error('Failed to fetch users');
  }
};

domainで定義された UserRepository インターフェースをDI(依存性の注入)で受け取ります。
usecase は具体的なデータ取得方法を一切知らず、インターフェースのメソッド(repository.getAll)を呼び出すだけとなっています。
つまり「依存関係の逆転」を適用しており、usecase は抽象に依存し、具体的なデータ取得先や実装から完全に分離されています。また、ブラウザAPIやReactに直接依存してはいけない領域です。

Presenters

presenters/user.ts
import type { User } from '../domain/user';
import type { UserApiResponse } from '../infra/drivers/user';

export const toUser = (response: UserApiResponse): User => {
  return {
    id: response.id,
    name: response.name,
    avatar: response.avatar,
  };
};

受け取った外部のデータ構造(UserApiResponse)を、domain 層のモデル(User)に変換する責務を持ちます。いわゆる腐敗防止層(Anti-Corruption Layer)として機能させています。
これにより、APIの仕様変更があった場合の修正はこの toUser 関数内に閉じ込められ、内部への影響を防ぎます。
フロントエンドの世界と外部との境界(ポート)を設けるイメージです。
個人的にはポートと考えるのが好きです。

Adapters

src/adapters/user/adapter.ts
import { Err, Ok, type Result } from '../domain/result';
import type { User, UserRepository } from '../domain/user';
import type { UserDriver } from '../infra/drivers/user';
import { toUser } from '../presenters/user';

export const createAdapter = (client: UserDriver): UserRepository => {
  const getAll = async (): Promise<Result<User[]>> => {
    try {
      const response = await client.getAll();
      const users = response.map(toUser);
      return Ok(users);
    } catch (error) {
      return Err(error);
    }
  };

  return { getAll };
};

ここでは、domain で定義された UserRepository インターフェースの具体的な実装を行います。
client を使って外部からデータを取得し、presenter(toUser)により domain モデルに変換し、usecase に結果を返します。
client をDIしているため「どこに向けてリクエストしてるのかわからないが、与えられたクライアントの getAll を呼び出すことで期待するデータが得られる状態」が保証されています。
これにより、もしデータソースが変更になっても、新しい client を注入するだけで済むため、adapter の変更は最小限となる利点があります。

Infra

src/infra/drivers/user/client.ts
import type { HTTPClient } from '../../../libs/http';
import type { UserApiResponse } from './types';

export interface UserDriver {
  getAll: () => Promise<UserApiResponse[]>;
}

export const createUserClient = (httpClient: HTTPClient): UserDriver => {
  const getAll = async (): Promise<UserApiResponse[]> => {
    const { body } = await httpClient.get<UserApiResponse[]>('/users');
    return body;
  };

  return {
    getAll,
  };
};
src/infra/drivers/user/types.ts
export interface UserApiResponse {
  createdAt: string;
  name: string;
  avatar: string;
  id: string;
}

ここでは、外部とやり取りするための具体的な実装を定義します。infra 層だけが「/users エンドポイントを叩く」といった具体的なコミュニケーション手段を知っている状態にしています。
外部APIのレスポンスの型(UserApiResponse)もここで定義します。

View

src/hooks/useUsers.ts
import { useEffect, useState } from 'react';
import { createAdapter } from '../adapters/user';
import type { User } from '../domain/user';
import { httpClient } from '../infra/client';
import { createUserClient } from '../infra/drivers/user';
import { getUsers } from '../usecase/getUsers';

const client = createUserClient(httpClient);
const adapter = createAdapter(client);

export const useUsers = () => {
  ...

  useEffect(() => {
    const fetch = async () => {
      const users = await getUsers(adapter);
      return users;
    };

    fetch()
      .then(result => {
        setUsers(result);
      })
      ...
  }, []);

  return {
    users,
    isLoading,
    isError,
  };
};

Reactコンポーネント(この例ではカスタムフック)が、アプリケーションのロジックを実行する起点となります。(useEffectの使い方については見逃してください)
infra の client と、それをラップした adapter を生成し、usecase に DI して実行しています。
View は usecase が返す domain モデルを受け取りUIの状態として管理します。

infra の client と、それをラップした adapter を生成し、usecase に DI して実行しています。

この例では、説明を簡潔にするため、モジュールレベルでの依存性の組み立てを行っています。
実際には Context API で注入する方法等あるかと思いますが、私自身も最適な実装を模索中です。
実践例やベストプラクティスがあれば、ぜひコメントで教えてください。

まとめ

今回、フロントエンドでクリーンアーキテクチャを実装しようと試みました。
The Clean Architectureが必ずしも全てのプロジェクトで正しいわけではないです。しかし、DIによる「データソースの抽象化」や、Presenterによる「腐敗防止層」といった、レイヤーを分離する基本的な考え方は今後のアーキテクチャ設計に生きる知見だと思いました。
また、今回のクリーンアーキテクチャの実装アプローチは、特定の技術を剥がす際にとても有用だと感じました。技術の移り変わりが早いフロントエンド領域において、剥がしやすさを考慮した設計は一定の価値がありそうです。
ただ、現実的には依存性逆転の法則などに厳密に固執しなくても、まずはレイヤーがしっかり分離されていさえすれば十分にクリーンであり、多くのプロジェクトでうまく機能するのではないかと個人的に思います。

もしご意見や改善点がありましたら、ぜひご指摘ください。

Discussion