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 の interface や type を使って抽象を定義し、実装の詳細を隠蔽するのです。
アーキテクチャの層構造
クリーンアーキテクチャを 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')
})
})
エラーケースのテストも簡単に書けます。このように、インターフェースに依存することで、テストが容易になるのです。
ディレクトリ構成
プロジェクト全体のディレクトリ構成は以下のようになります。
| # | ディレクトリ | 説明 |
|---|---|---|
| 1 | domain/entities/ | ドメインエンティティの型定義 |
| 2 | domain/repositories/ | リポジトリのインターフェース |
| 3 | application/usecases/ | ユースケースの実装 |
| 4 | infrastructure/repositories/ | リポジトリの具体実装 |
| 5 | composables/ | Vue Composable の実装 |
| 6 | components/ | 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 の型システムを活用することで、インターフェースと実装の整合性を保ち、コンパイル時にエラーを検出できます。これにより、リファクタリングや機能追加が安全に行えるようになるでしょう。
最初は層が増えて複雑に感じるかもしれませんが、プロジェクトが成長するにつれて、この構造の恩恵を実感できるはずです。特に、複数人での開発や長期的な保守が必要なプロジェクトでは、クリーンアーキテクチャの採用を検討する価値があります。
小さく始めて、必要に応じて各層を充実させていくアプローチもおすすめですよ。まずは一つのドメインから実装してみて、パターンに慣れてから他の機能にも適用していくとよいでしょう。
関連リンク
articleVue.js クリーンアーキテクチャ:Composable・サービス層・依存逆転の型
articleVue.js Router 速見表:ガード・遅延ロード・トランジションの定石
articleVue.js Monorepo 構築:pnpm/Turborepo でアプリとパッケージを一元管理
articleVue.js ルーター戦略比較:ネスト/動的セグメント/ガードの設計コスト
articleVue.js でメモリリーク?watch/effect/イベント登録の落とし穴と検知法
articleVue.js リアクティビティ内部解剖:Proxy/ref/computed を図で読み解く
articleZod の再帰型・木構造設計:`z.lazy` でツリー/グラフを安全に表現
articleCline ガバナンス運用:ポリシー・承認フロー・監査証跡の整備
articleYarn の歴史と進化:Classic(v1) から Berry(v2/v4) まで一気に把握
articleClaude Code 中心の開発プロセス設計:要求 → 設計 → 実装 → 検証の最短動線
articleWeb Components のスロット設計術:命名付き slot/fallback/`slotchange` の実戦パターン
articleBun vs Node.js 徹底比較:起動時間・スループット・メモリの実測レポート
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来