T-CREATOR

Vue.js クリーンアーキテクチャ:Composable・サービス層・依存逆転の型

Vue.js クリーンアーキテクチャ:Composable・サービス層・依存逆転の型

Vue.js でアプリケーションを開発する際、初めは小さなコンポーネントから始まりますが、機能が増えるにつれてコードの保守性が課題になってきますよね。そんな時、クリーンアーキテクチャの考え方を取り入れることで、テストしやすく、変更に強いコードを実現できます。

この記事では、Vue.js における Composable、サービス層、そして依存性逆転の原則を TypeScript の型システムと組み合わせて実装する方法を、実践的な例とともに解説していきます。クリーンアーキテクチャと聞くと難しく感じるかもしれませんが、一つずつ丁寧に見ていけば、きっと理解できるでしょう。

背景

クリーンアーキテクチャとは

クリーンアーキテクチャは、Robert C. Martin(通称 Uncle Bob)が提唱したソフトウェア設計の原則です。ビジネスロジックを中心に据え、フレームワークやデータベースなどの外部要素から独立させることを目指します。

この設計思想では、依存関係が常に外側から内側へ向かうように構成されます。つまり、ビジネスロジック(ドメイン層)は UI やデータベースの実装詳細に依存せず、逆に外側の層が内側の抽象に依存する形になるのです。

Vue.js における課題

Vue.js のような UI フレームワークでは、コンポーネント内にビジネスロジックを直接書いてしまいがちです。小規模なアプリケーションでは問題になりませんが、プロジェクトが成長するにつれて、以下のような問題が顕在化してきます。

テストの困難さ、コンポーネント間のロジック重複、API の変更による広範囲な修正、ビジネスロジックと UI ロジックの混在など、保守性を損なう要因が増えていくでしょう。

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

mermaidflowchart TB
  domain["ドメイン層<br/>(ビジネスロジック)"]
  usecase["ユースケース層<br/>(アプリケーションロジック)"]
  interface["インターフェース層<br/>(Composable・Repository)"]
  framework["フレームワーク層<br/>(Vue Components・API)"]

  framework -->|依存| interface
  interface -->|依存| usecase
  usecase -->|依存| domain

  style domain fill:#e1f5e1
  style usecase fill:#e3f2fd
  style interface fill:#fff3e0
  style framework fill:#fce4ec

この図が示すように、依存の方向は常に外から内へ向かい、中心にあるドメイン層は何にも依存しません。

課題

従来の Vue.js 開発における問題点

通常の Vue.js 開発では、コンポーネント内で直接 API を呼び出したり、ビジネスロジックを記述したりすることが多いですね。このアプローチには以下のような課題があります。

まず、コンポーネントが API の実装詳細に強く依存してしまうため、API の仕様変更時に多数のコンポーネントを修正する必要が生じます。また、ビジネスロジックがコンポーネントに散らばることで、同じロジックが複数箇所に重複して書かれることもあるでしょう。

さらに、UI とロジックが密結合しているため、ロジックだけを単体テストすることが困難になります。モックの作成も複雑になり、テストカバレッジを上げることが難しくなってしまうのです。

具体的な問題のシナリオ

例えば、ユーザー情報を表示するコンポーネントを考えてみましょう。最初は REST API からデータを取得していたとします。ところが、後から GraphQL に移行することになった場合、すべてのコンポーネントで API 呼び出しのコードを書き換える必要が出てきます。

下記の図は、従来のアプローチにおける問題を示しています。

mermaidflowchart LR
  comp1["UserProfile<br/>コンポーネント"]
  comp2["UserList<br/>コンポーネント"]
  comp3["UserSettings<br/>コンポーネント"]
  api["REST API"]

  comp1 -->|直接依存| api
  comp2 -->|直接依存| api
  comp3 -->|直接依存| api

  style api fill:#ffcdd2
  style comp1 fill:#fff3e0
  style comp2 fill:#fff3e0
  style comp3 fill:#fff3e0

このように、複数のコンポーネントが API に直接依存していると、API の変更時にすべてのコンポーネントを修正しなければならず、保守コストが高くなってしまいます。

型安全性の欠如

また、JavaScript で開発している場合や、TypeScript を使っていても型定義が不十分な場合、実行時エラーが発生しやすくなります。API のレスポンス構造が変わったことに気づかず、本番環境でエラーが発生するといった事態も起こりえるでしょう。

解決策

依存性逆転の原則(DIP)の適用

依存性逆転の原則(Dependency Inversion Principle)は、SOLID 原則の一つです。この原則は「上位モジュールは下位モジュールに依存してはならず、両者とも抽象に依存すべきである」と定めています。

Vue.js においては、Composable とサービス層の間にインターフェース(抽象)を設けることで、この原則を実現できます。具体的には、TypeScript の interfacetype を使って抽象を定義し、実装の詳細を隠蔽するのです。

アーキテクチャの層構造

クリーンアーキテクチャを Vue.js に適用する際、以下のような層構造を採用します。

ドメイン層では、ビジネスエンティティや値オブジェクトを定義し、ユースケース層では、アプリケーション固有のビジネスロジックを実装します。そしてインターフェース層で、Composable と Repository のインターフェースを定義し、最後にフレームワーク層で、Vue コンポーネントと API の具体的な実装を行うのです。

以下の図は、改善後のアーキテクチャを示しています。

mermaidflowchart TB
  subgraph presentation["プレゼンテーション層"]
    component["Vue Component"]
  end

  subgraph application["アプリケーション層"]
    composable["Composable"]
    usecase["UseCase"]
  end

  subgraph domain["ドメイン層"]
    entity["Entity(型定義)"]
    interface["Repository<br/>Interface"]
  end

  subgraph infrastructure["インフラ層"]
    repository["Repository<br/>実装"]
    api["API Client"]
  end

  component -->|使用| composable
  composable -->|使用| usecase
  usecase -->|依存| interface
  usecase -->|使用| entity
  repository -->|実装| interface
  repository -->|使用| api
  composable -->|注入| repository

  style entity fill:#e1f5e1
  style interface fill:#e1f5e1
  style usecase fill:#e3f2fd
  style composable fill:#fff3e0
  style component fill:#fff3e0
  style repository fill:#f3e5f5
  style api fill:#f3e5f5

図で理解できる要点:

  • コンポーネントは Composable を通じてロジックを利用
  • UseCase はインターフェースに依存し、実装には依存しない
  • Repository の実装は Composable で注入される

TypeScript による型安全性の確保

TypeScript の型システムを活用することで、コンパイル時に多くのエラーを検出できます。インターフェースと実装クラスの型定義を厳密にすることで、API の変更や不整合を早期に発見できるようになるでしょう。

具体例

ドメイン層の実装

まず、ビジネスエンティティの型を定義します。このファイルには、アプリケーション全体で使用するユーザーエンティティの型定義を記述します。

typescript// domain/entities/User.ts

/**
 * ユーザーエンティティ
 * ビジネスロジックの中心となるドメインモデル
 */
export interface User {
  /** ユーザーID */
  id: string;
  /** ユーザー名 */
  name: string;
  /** メールアドレス */
  email: string;
  /** 作成日時 */
  createdAt: Date;
}

次に、Repository のインターフェースを定義します。このインターフェースが抽象となり、具体的な実装の詳細からドメイン層を独立させます。

typescript// domain/repositories/IUserRepository.ts

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

/**
 * ユーザーリポジトリのインターフェース
 * データの取得・保存方法の抽象を定義
 */
export interface IUserRepository {
  /**
   * ユーザーIDでユーザーを取得
   * @param id - ユーザーID
   * @returns ユーザー情報、存在しない場合はnull
   */
  findById(id: string): Promise<User | null>;

  /**
   * 全ユーザーを取得
   * @returns ユーザーリスト
   */
  findAll(): Promise<User[]>;

  /**
   * ユーザーを作成
   * @param user - 作成するユーザー情報
   * @returns 作成されたユーザー
   */
  create(
    user: Omit<User, 'id' | 'createdAt'>
  ): Promise<User>;
}

このインターフェースは、データの取得方法が REST API でも GraphQL でも変わらない抽象的な操作を定義しています。

ユースケース層の実装

ユースケースクラスでは、アプリケーション固有のビジネスロジックを実装します。このクラスのコンストラクタで、リポジトリのインターフェースを受け取る点に注目してください。

typescript// application/usecases/GetUserUseCase.ts

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

/**
 * ユーザー取得ユースケース
 * ビジネスルールに基づいてユーザー情報を取得
 */
export class GetUserUseCase {
  /**
   * @param userRepository - ユーザーリポジトリ(インターフェースに依存)
   */
  constructor(
    private readonly userRepository: IUserRepository
  ) {}

  /**
   * ユーザーIDでユーザーを取得
   * @param id - ユーザーID
   * @returns ユーザー情報
   * @throws ユーザーが見つからない場合はエラー
   */
  async execute(id: string): Promise<User> {
    const user = await this.userRepository.findById(id);

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

    return user;
  }
}

ここでのポイントは、具体的な Repository の実装クラスではなく、IUserRepository インターフェースに依存している点です。これにより、テスト時にモックを注入することが容易になります。

次に、ユーザーリストを取得するユースケースを見てみましょう。

typescript// application/usecases/ListUsersUseCase.ts

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

/**
 * ユーザーリスト取得ユースケース
 */
export class ListUsersUseCase {
  constructor(
    private readonly userRepository: IUserRepository
  ) {}

  /**
   * 全ユーザーを取得し、名前順でソート
   * @returns ソート済みユーザーリスト
   */
  async execute(): Promise<User[]> {
    const users = await this.userRepository.findAll();

    // ビジネスルール: 名前順でソート
    return users.sort((a, b) =>
      a.name.localeCompare(b.name)
    );
  }
}

このユースケースでは、取得したユーザーリストを名前順でソートするというビジネスルールを実装しています。

インフラ層の実装(Repository の具体実装)

次に、Repository インターフェースの具体的な実装を作成します。このクラスは、実際の API 通信を行う責務を持ちます。

typescript// infrastructure/repositories/UserRepository.ts

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

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

ここでは、API のレスポンス型を定義しています。API がスネークケースを使用している場合でも、内部的にはキャメルケースに変換することで、コードの一貫性を保てます。

typescript// infrastructure/repositories/UserRepository.ts(続き)

/**
 * ユーザーリポジトリの実装
 * REST API を使用したデータ取得
 */
export class UserRepository implements IUserRepository {
  /**
   * @param apiBaseUrl - API のベース URL
   */
  constructor(private readonly apiBaseUrl: string) {}

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

このプライベートメソッドで、API のレスポンス形式をドメインエンティティに変換します。

typescript// infrastructure/repositories/UserRepository.ts(続き)

  async findById(id: string): Promise<User | null> {
    try {
      const response = await fetch(`${this.apiBaseUrl}/users/${id}`)

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

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

      const data: UserApiResponse = await response.json()
      return this.mapToEntity(data)
    } catch (error) {
      console.error('Failed to fetch user:', error)
      throw error
    }
  }

findById メソッドでは、API からユーザー情報を取得し、エンティティに変換して返します。404 の場合は null を返す仕様です。

typescript// infrastructure/repositories/UserRepository.ts(続き)

  async findAll(): Promise<User[]> {
    try {
      const response = await fetch(`${this.apiBaseUrl}/users`)

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

      const data: UserApiResponse[] = await response.json()
      return data.map(item => this.mapToEntity(item))
    } catch (error) {
      console.error('Failed to fetch users:', error)
      throw error
    }
  }

全ユーザーを取得する findAll メソッドでは、配列をマップしてエンティティのリストに変換します。

typescript// infrastructure/repositories/UserRepository.ts(続き)

  async create(user: Omit<User, 'id' | 'createdAt'>): Promise<User> {
    try {
      const response = await fetch(`${this.apiBaseUrl}/users`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          name: user.name,
          email: user.email
        })
      })

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

      const data: UserApiResponse = await response.json()
      return this.mapToEntity(data)
    } catch (error) {
      console.error('Failed to create user:', error)
      throw error
    }
  }

create メソッドでは、新しいユーザーを作成し、作成されたユーザー情報を返します。

Composable の実装

Composable は、Vue コンポーネントからユースケースを利用するための橋渡し役を担います。ここで依存性の注入(DI)を行うのがポイントです。

typescript// composables/useUser.ts

import { ref, type Ref } from 'vue';
import type { User } from '../domain/entities/User';
import { GetUserUseCase } from '../application/usecases/GetUserUseCase';
import { ListUsersUseCase } from '../application/usecases/ListUsersUseCase';
import type { IUserRepository } from '../domain/repositories/IUserRepository';

/**
 * ユーザー管理用 Composable
 * @param repository - ユーザーリポジトリの実装を注入
 */
export function useUser(repository: IUserRepository) {
  // リアクティブな状態管理
  const user: Ref<User | null> = ref(null);
  const users: Ref<User[]> = ref([]);
  const loading: Ref<boolean> = ref(false);
  const error: Ref<Error | null> = ref(null);

  return {
    user,
    users,
    loading,
    error,
  };
}

ここでは、Composable の基本的な状態を定義しています。ユーザー情報、ローディング状態、エラー状態を管理するリアクティブな参照を用意します。

typescript// composables/useUser.ts(続き)

/**
 * ユーザーIDでユーザーを取得
 */
const fetchUser = async (id: string): Promise<void> => {
  loading.value = true;
  error.value = null;

  try {
    const useCase = new GetUserUseCase(repository);
    user.value = await useCase.execute(id);
  } catch (e) {
    error.value = e as Error;
    user.value = null;
  } finally {
    loading.value = false;
  }
};

fetchUser 関数では、ユースケースをインスタンス化し、リポジトリを注入しています。エラーハンドリングとローディング状態の管理も行います。

typescript// composables/useUser.ts(続き)

/**
 * 全ユーザーを取得
 */
const fetchUsers = async (): Promise<void> => {
  loading.value = true;
  error.value = null;

  try {
    const useCase = new ListUsersUseCase(repository);
    users.value = await useCase.execute();
  } catch (e) {
    error.value = e as Error;
    users.value = [];
  } finally {
    loading.value = false;
  }
};

return {
  user,
  users,
  loading,
  error,
  fetchUser,
  fetchUsers,
};

fetchUsers 関数でも同様に、ユースケースを使ってユーザーリストを取得します。最後に、すべての状態と関数を返却するのです。

Vue コンポーネントでの使用

最後に、実際の Vue コンポーネントで Composable を使用する例を見てみましょう。

typescript<!-- components/UserProfile.vue -->

<script setup lang="ts">
import { onMounted } from 'vue'
import { useUser } from '../composables/useUser'
import { UserRepository } from '../infrastructure/repositories/UserRepository'

// Props でユーザーIDを受け取る
const props = defineProps<{
  userId: string
}>()

// リポジトリの実装を注入
const repository = new UserRepository(import.meta.env.VITE_API_BASE_URL)
const { user, loading, error, fetchUser } = useUser(repository)
</script>

コンポーネントの setup スクリプトでは、環境変数から API の URL を取得し、Repository をインスタンス化します。そして、その Repository を Composable に注入するのです。

vue<!-- components/UserProfile.vue(続き) -->

<script setup lang="ts">
// マウント時にユーザー情報を取得
onMounted(async () => {
  await fetchUser(props.userId);
});
</script>

<template>
  <div class="user-profile">
    <!-- ローディング中の表示 -->
    <div v-if="loading" class="loading">読み込み中...</div>

    <!-- エラー時の表示 -->
    <div v-else-if="error" class="error">
      エラーが発生しました: {{ error.message }}
    </div>
  </div>
</template>

テンプレート部分では、ローディング状態とエラー状態に応じて表示を切り替えます。

vue<!-- components/UserProfile.vue(続き) -->

    <!-- ユーザー情報の表示 -->
    <div v-else-if="user" class="user-info">
      <h2>{{ user.name }}</h2>
      <p>メール: {{ user.email }}</p>
      <p>登録日: {{ user.createdAt.toLocaleDateString('ja-JP') }}</p>
    </div>

    <!-- データがない場合 -->
    <div v-else class="no-data">
      ユーザー情報が見つかりません
    </div>
  </div>
</template>

ユーザー情報が取得できた場合は、その内容を表示します。

次に、ユーザーリストを表示するコンポーネントの例を見てみましょう。

vue<!-- components/UserList.vue -->

<script setup lang="ts">
import { onMounted } from 'vue';
import { useUser } from '../composables/useUser';
import { UserRepository } from '../infrastructure/repositories/UserRepository';

// リポジトリを注入
const repository = new UserRepository(
  import.meta.env.VITE_API_BASE_URL
);
const { users, loading, error, fetchUsers } =
  useUser(repository);

// マウント時に全ユーザーを取得
onMounted(async () => {
  await fetchUsers();
});
</script>

このコンポーネントでも、同じ Composable を使用していますが、fetchUsers 関数を呼び出してユーザーリストを取得します。

vue<!-- components/UserList.vue(続き) -->

<template>
  <div class="user-list">
    <h1>ユーザー一覧</h1>

    <!-- ローディング中 -->
    <div v-if="loading">読み込み中...</div>

    <!-- エラー発生時 -->
    <div v-else-if="error" class="error">
      {{ error.message }}
    </div>

    <!-- ユーザーリスト表示 -->
    <ul v-else>
      <li
        v-for="user in users"
        :key="user.id"
        class="user-item"
      >
        <strong>{{ user.name }}</strong>
        <span>{{ user.email }}</span>
      </li>
    </ul>
  </div>
</template>

リストは v-for ディレクティブを使って、取得したユーザー配列をレンダリングします。

テストの実装例

クリーンアーキテクチャの大きなメリットの一つが、テストのしやすさです。モックリポジトリを作成して、ユースケースを簡単にテストできます。

typescript// __tests__/usecases/GetUserUseCase.spec.ts

import { describe, it, expect, vi } from 'vitest';
import { GetUserUseCase } from '../../application/usecases/GetUserUseCase';
import type { IUserRepository } from '../../domain/repositories/IUserRepository';
import type { User } from '../../domain/entities/User';

/**
 * モックリポジトリの作成
 * インターフェースを実装した偽のリポジトリ
 */
const createMockRepository = (): IUserRepository => {
  const mockUser: User = {
    id: '1',
    name: 'Test User',
    email: 'test@example.com',
    createdAt: new Date('2024-01-01'),
  };

  return {
    findById: vi.fn().mockResolvedValue(mockUser),
    findAll: vi.fn().mockResolvedValue([mockUser]),
    create: vi.fn().mockResolvedValue(mockUser),
  };
};

モックリポジトリを作成することで、実際の API を呼び出さずにテストできます。

typescript// __tests__/usecases/GetUserUseCase.spec.ts(続き)

describe('GetUserUseCase', () => {
  it('ユーザーIDでユーザーを取得できる', async () => {
    // Arrange: テストの準備
    const mockRepository = createMockRepository()
    const useCase = new GetUserUseCase(mockRepository)

    // Act: テスト対象の実行
    const user = await useCase.execute('1')

    // Assert: 結果の検証
    expect(user).toBeDefined()
    expect(user.id).toBe('1')
    expect(user.name).toBe('Test User')
    expect(mockRepository.findById).toHaveBeenCalledWith('1')
  })

このテストでは、ユーザーが正しく取得できることを確認しています。

typescript// __tests__/usecases/GetUserUseCase.spec.ts(続き)

  it('ユーザーが見つからない場合はエラーをスロー', async () => {
    // Arrange: ユーザーが見つからないケース
    const mockRepository = createMockRepository()
    mockRepository.findById = vi.fn().mockResolvedValue(null)
    const useCase = new GetUserUseCase(mockRepository)

    // Act & Assert: エラーが発生することを確認
    await expect(useCase.execute('999')).rejects.toThrow('User not found: 999')
  })
})

エラーケースのテストも簡単に書けます。このように、インターフェースに依存することで、テストが容易になるのです。

ディレクトリ構成

プロジェクト全体のディレクトリ構成は以下のようになります。

#ディレクトリ説明
1domain​/​entities​/​ドメインエンティティの型定義
2domain​/​repositories​/​リポジトリのインターフェース
3application​/​usecases​/​ユースケースの実装
4infrastructure​/​repositories​/​リポジトリの具体実装
5composables​/​Vue Composable の実装
6components​/​Vue コンポーネント

この構成により、各層の責務が明確になり、コードの見通しが良くなります。

csssrc/
├── domain/
│   ├── entities/
│   │   └── User.ts
│   └── repositories/
│       └── IUserRepository.ts
├── application/
│   └── usecases/
│       ├── GetUserUseCase.ts
│       └── ListUsersUseCase.ts
├── infrastructure/
│   └── repositories/
│       └── UserRepository.ts
├── composables/
│   └── useUser.ts
└── components/
    ├── UserProfile.vue
    └── UserList.vue

まとめ

Vue.js にクリーンアーキテクチャを適用することで、保守性が高く、テストしやすいアプリケーションを構築できます。

Composable は UI とビジネスロジックの橋渡し役として機能し、サービス層(UseCase) はビジネスロジックをカプセル化します。そして依存性逆転の原則により、抽象に依存することで実装の詳細から独立できるのです。

TypeScript の型システムを活用することで、インターフェースと実装の整合性を保ち、コンパイル時にエラーを検出できます。これにより、リファクタリングや機能追加が安全に行えるようになるでしょう。

最初は層が増えて複雑に感じるかもしれませんが、プロジェクトが成長するにつれて、この構造の恩恵を実感できるはずです。特に、複数人での開発や長期的な保守が必要なプロジェクトでは、クリーンアーキテクチャの採用を検討する価値があります。

小さく始めて、必要に応じて各層を充実させていくアプローチもおすすめですよ。まずは一つのドメインから実装してみて、パターンに慣れてから他の機能にも適用していくとよいでしょう。

関連リンク