Vue 3×Clean Architectureで破綻しないSPA設計 ─ Pinia UseCase型安全化と依存関係逆転【第3回】
📖 Vue 3×Clean Architectureで破綻しないSPA設計 ─ Pinia UseCase型安全化と依存関係逆転の実践【第3回】
📑 概要
中〜大規模Vueアプリケーション開発において、コードの複雑化や保守性の低下は避けがたい課題ですよね?
このシリーズでは、この課題に対しClean Architectureの原則を適用することで、堅牢でスケーラブルなSPA設計を目指しています。
第3回となる今回は、前回明確にしたComposableとPiniaの責務分担をさらに深掘りし、Pinia StoreをUseCase層として型安全に実装する方法、そしてClean Architectureの肝である 依存関係逆転の原則(DIP) をVue SPAで具体的に実践する方法を体系的に解説します。これを読めばSPAのテスト性と将来の拡張性を大きく高められるはずです!
🎯 対象読者
- Piniaの型安全な設計にどうしたらいいか迷っている方
- UseCase層とAPI Gatewayの依存方向で「あれ?」と悩んでしまう方
- Vue + Clean ArchitectureでDIPを適用したいけど、具体的な方法が分からない方
- テストしやすいアプリケーション構造を本気で求めている方
🧭 前回までのおさらい
本シリーズは、Clean Architectureの原則に基づき、Vue 3アプリケーションを設計するための具体的な手法を解説しています。
- 第1回はこちら:SPA全体の構造と、PiniaをUseCase層に据えた設計方針について解説しました。
- 第2回はこちら:Composableの責務をUI Composable、Application Composable、Utility Composableに明確に分離することで、コードの肥大化と破綻を防ぐ実践的な方法を示しました。特に、Pinia Store(UseCase層)とApplication Composableの責任範囲を明確にしました。
⛳️ 今回のテーマ
いよいよ、クリーンアーキテクチャの核心部分に踏み込みます!
- Pinia Storeの型安全実装法:ビジネスロジックをTypeScriptで堅牢に記述し、意図しない型崩れを防ぐ具体的なテクニック
- 依存関係逆転の原則(DIP)適用手法:UIやDB、APIといった「詳細」に依存しない、柔軟でテストしやすい設計を実現する方法
- 依存性注入(DI)の実装パターン:テスト環境と本番環境で依存先を柔軟に差し替え可能にし、開発・テスト効率を大幅に向上させる方法
📘 Clean ArchitectureとDIPを整理
クリーンアーキテクチャの目的は、コードを関心事に基づいて層状に分離し、システムの保守性、テスト容易性、そして技術的な選択の自由度を高めることです。その中心にあるのが、揺るぎない 「依存性ルール(Dependency Rule)」。
SPA開発においてDIPが必要とされる理由
SPA開発ではAPIクライアントの仕様変更や非同期制御の実装方針がプロジェクト途中で変わることがよくあります。
その際、それがビジネスロジックと密結合しているとビジネスロジックの修正を招き、やがてプロジェクトの破綻に繋がります。
そのため、予め、中心となるビジネスロジックを依存関係逆転の原則(DIP)で、疎結合にしておくことは、中~大規模SPA開発において有効に機能します。
Clean Architectureの依存方向原則
クリーンアーキテクチャの根幹をなすのは、「外側の層は内側の層に依存し、内側の層は外側の層に依存しない」という一方向の依存関係です。
内側の層(Entity・UseCase)
- Entity: アプリケーションの「不変のビジネスルール」を表す
- UseCase: Entityの操作やビジネスルールの適用を担う
ここがアプリケーションの 「魂」 となるビジネスロジックの核。UIやデータベース、Webフレームワークといった外部の具体的な技術には 一切依存しません。
外側の層(Interface Adapter・Framework)
内側の層で定義されたインターフェースを実装したり、外部の具体的な技術(DBアクセス、API通信、UI描画など)を扱ったりします。
この原則により、仮にデータベースやUIフレームワークが変わったとしても、あなたのビジネスロジックは影響を受けずに済みます。まさに未来の変化に強い設計の秘訣となります。
依存関係逆転の原則(DIP)とは
DIPは、この依存性ルールを具体的に実現するための、オブジェクト指向における非常に強力な設計原則です。
- 高水準モジュール(UseCase)は低水準モジュール(Gateway)に依存してはならない。両方とも抽象に依存すべきである。
- 抽象(インターフェース)は詳細に依存してはならない。詳細が抽象に依存すべきである。
これを今回の文脈に当てはめてみましょう。
-
高水準モジュール:
UseCase
(Pinia Storeのactions
) - 「ユーザー情報を取得する」というビジネスロジック(何をするか)。 -
低水準モジュール:
Gateway
の具象実装 (例: HTTPクライアントを使ったAPI呼び出し) - 「どのようにデータを取得するか」という具体的な手段。 -
抽象:
Gateway Interface
- 「ユーザー情報を取得するための契約(こういうメソッドを持っているはず)」という約束事。
つまり、UseCase
は具体的なGateway
実装(例えばuserApiGateway
)に直接依存するのではなく、UserGateway
という 「契約書」(Typescript言語におけるインターフェース)に依存 します。そして、具体的なGateway
実装がその「契約」に適合するように作られることで、UseCaseから見た依存の方向が逆転し、UseCaseがインフラ層の詳細から完全に独立するのです。これが「依存関係逆転」と呼ばれるゆえんです。
📝 PiniaをUseCase層とする設計指針
第1回で述べたように、Pinia Storeのactions
をUseCase層の責務を担う場所とします。この設計をDIPの観点から見ると、いくつかの重要な指針が生まれます。
Store間依存を禁止
Pinia Store間で直接依存(あるStoreが別のStoreのActionを直接呼び出すなど)することは、循環参照を引き起こしたり、Storeの責務が肥大化したり、ドメインの独立性が失われたりする原因となります。結果として、テストが困難になり、変更が全体に波及しやすくなるため、原則として禁止します。
- 💡 代替案: もしあるUseCaseが別のUseCaseの結果に依存する場合、Application ComposableがそれらのUseCaseをオーケストレーションすることで、Store間の疎結合を保ちます。または、複数の関連UseCaseをまとめた新しいUseCase(Store)を作成し、責務を適切に集約することを検討しましょう。
Composable → Storeの一方向依存
Application Composable
はPinia Store
を呼び出してUseCaseを実行しますが、Pinia Store
がApplication Composable
(UI層)に依存することはありません。これにより、ビジネスロジック(Store)がUI層から完全に独立します。
Store → Gateway Interface依存
ここがDIPの肝です! Pinia Store(UseCase)は、外部サービスとの通信を行うGateway
の具象実装ではなく、Gateway Interface
(抽象)に依存します。これにより、APIの実装詳細がStoreから隠蔽され、インフラ層に依存しない純粋なビジネスロジックが保たれます。
State・Action型の定義例
まずはPinia Storeの基本的な型定義を見てみましょう。ここではまだDIPは適用していません。
// src/stores/user.ts
// Piniaのstateの型定義
interface UserState {
user: User | null // User型は別途定義されたドメインの型を想定
}
// UserStoreの定義
export const useUserStore = defineStore('user', {
state: (): UserState => ({
user: null,
}),
actions: {
async fetchUser(id: string): Promise<void> {
// ここでuserGatewayを直接呼び出したいが...
// this.user = await userGateway.getUser(id) // << userGatewayはどこから来た?問題
},
},
})
上記のコメントにあるように、userGateway
がどこから来るのか、そして具体的にどのuserGateway
を使うのかが、この時点では不明瞭です。これを解決する手段として、次のセクションで依存性注入(DI)を活用する方法を解説します。
🛠 UseCase間依存の排除とDIP適用法
UseCase(ここではPinia StoreのAction)が特定のGatewayの具象実装に直接依存してしまうと、インフラ層の変更がUseCaseに波及し、テストも困難になります。これを解決するために、DIPを適用します。
Gateway Interface定義
まず、UseCaseが依存すべき「抽象」であるGateway Interface
を定義します。これはUseCase層とInfrastructure層の境界線に位置する、どちらにも属さずに依存関係の接点になる存在であり、概念的にはUseCase層に属します。
// src/usecases/gateways/userGateway.ts
//(💡 UseCase層の一部と考える)
// ⚠️ Clean Architecture的にはEntityとUseCaseの中間の役割を持つが、Vue環境での実務上はUseCase層に含める形を取っている
import { User } from '@/domain/user'; // ドメイン層で定義されたUserエンティティ
export interface UserGateway {
getUser(id: string): Promise<User>;
// 他のユーザー関連APIもここに定義していきます
}
このインターフェースは、「ユーザー情報をIDで取得できる」という契約を定義しているにすぎません。UseCase層はこの契約(抽象)しか知らないため、具体的なデータ取得方法(REST API、GraphQL、ローカルDBなど)には一切依存しません。
Gatewayの具象実装例
次に、このUserGateway
インターフェースを実装する具体的なGateway
を、インフラストラクチャ層に定義します。
// src/infrastructures/gateways/userApiGateway.ts (💡 Infrastructure層)
import { UserGateway } from '@/usecases/gateways/userGateway'; // 定義したインターフェースをインポート
import { apiClient } from '@/infrastructures/api/apiClient'; // HTTPクライアント(Axiosなど)
export const userApiGateway: UserGateway = {
async getUser(id: string): Promise<User> {
const res = await apiClient.get<User>(`/users/${id}`); // 実際のAPI呼び出し
return res.data;
},
// 他のメソッドもインターフェースに沿って実装
};
このuserApiGateway
は、UserGateway
インターフェースの契約に則って、具体的なHTTP通信によるデータ取得を実装しています。重要なのは、UseCase層はこのuserApiGateway
の存在やその具体的な実装方法を知らない、という点です。
⚙️ 依存性注入(DI)の実装パターン
DIPを実践するために、UseCase(Pinia Store)にGateway
の具象実装を外部から注入する依存性注入(DI) を適用します。これにより、UseCaseは抽象に依存したまま、実行時に具体的な実装を受け取ることができます。
ここでは、Vue 3環境における主要なDIパターンを2つ紹介します。
1. Provide/Inject DI(小規模アプリやコンポーネントツリー内でのDI向き)
Vue 3のprovide/inject
を使ったDIは、Vueコンポーネントツリー内で依存性を渡す場合に手軽で便利です。
// src/usecases/gateways/keys.ts (DIキーを定義)
import type { InjectionKey } from 'vue';
import type { UserGateway } from './userGateway';
export const UserGatewayKey: InjectionKey<UserGateway> = Symbol('UserGateway');
// src/main.ts または App.vue など、アプリケーションのエントリーポイント
import { createApp } from 'vue';
import App from './App.vue';
import { createPinia } from 'pinia';
import { provide } from 'vue';
import { UserGatewayKey } from '@/usecases/gateways/keys';
import { userApiGateway } from '@/infrastructures/gateways/userApiGateway'; // 具象実装をインポート
const app = createApp(App);
app.use(createPinia());
// アプリケーションのルートでUserGatewayの具象実装を提供
// これにより、下位のコンポーネントやPinia Storeで注入できるようになる
app.provide(UserGatewayKey, userApiGateway);
app.mount('#app');
// src/stores/user.ts (Pinia Store内で注入されたGatewayを利用)
import { defineStore } from 'pinia';
import { inject } from 'vue'; // Vueのinjectをインポート
import { UserGatewayKey } from '@/usecases/gateways/keys'; // キーをインポート
import { User } from '@/domain/user';
interface UserState {
user: User | null;
}
export const useUserStore = defineStore('user', {
state: (): UserState => ({
user: null,
}),
actions: {
async fetchUser(id: string): Promise<void> {
// provideされたUserGatewayを注入!
// これでUseCaseは抽象(UserGatewayインターフェース)に依存できる
const userGateway = inject(UserGatewayKey);
if (!userGateway) {
// DI漏れを防ぐためのエラーハンドリング
throw new Error('UserGateway not provided. Ensure it is provided at the app root.');
}
this.user = await userGateway.getUser(id);
},
},
});
🤔 Provide/Injectの使いどころは?
この方法は、Vueコンポーネントツリー内で依存性を渡す場合に手軽ですが、以下の課題があります
- Vueコンポーネントツリー外のStore利用時に機能しない
- TypeScriptの型補完が弱まる
- DIの関係が暗黙化し、Provide漏れによる実行時エラーの危険がある
2. Factory関数によるDI(大規模SPAやユニットテスト重視の環境で推奨)
Pinia Storeのインスタンスを生成する際に、必要な依存性を引数として渡すFactory関数を用いる方法です。これにより、StoreがVueのprovide/inject
メカニズムに依存せず、より柔軟にテストや異なる環境で利用できるようになります。大規模なSPAや、徹底したユニットテストを重視するプロジェクトでは特におすすめです。
// src/stores/user.ts (Factory関数でDIを実装)
import { defineStore } from 'pinia';
import type { UserGateway } from '@/usecases/gateways/userGateway'; // インターフェースをインポート
import { User } from '@/domain/user';
interface UserState {
user: User | null;
}
// Factory関数としてStoreを定義
// この関数がUserGatewayの具体的な実装を受け取る
export const createUserStore = (userGateway: UserGateway) => {
return defineStore('user', {
state: (): UserState => ({
user: null,
}),
actions: {
async fetchUser(id: string): Promise<void> {
// 注入されたGatewayを利用!これでUseCaseは具象に依存しない
this.user = await userGateway.getUser(id);
},
},
});
};
// --- ここからがFactory関数の呼び出し方 ---
// src/main.ts の抜粋
import { createApp } from 'vue';
import App from './App.vue';
import { createPinia } from 'pinia';
import { userApiGateway } from '@/infrastructures/gateways/userApiGateway'; // 本番用具象実装をインポート
import { createUserStore } from '@/stores/user'; // Factory関数をインポート
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
// アプリケーション起動時にFactory関数を呼び出し、本番用Gatewayを注入してストアインスタンスを作成
// この `useUserStore` が、アプリケーション全体で利用される'user'ストアの「具象的な呼び出し元」となる
export const useUserStore = createUserStore(userApiGateway);
app.mount('#app');
このようにすることで、Pinia Store
は具象的なGateway
の実装に依存せず、アプリケーション起動時に一度だけ依存性を解決すれば良くなります。テスト時などには、後述するように別のGateway
実装(モックなど)を簡単に差し替えられるのが、このFactory関数の大きな強みです。
🧪 テストとモック化
DIPとDIの最大のメリットの一つは、テストの容易性です。UseCase(Pinia Store)が具体的なGateway実装に依存しないため、テスト時にはモックのGatewayを注入することで、UseCase単体を分離してテストできます。これにより、高速で信頼性の高いユニットテストが可能になります。
Mock Gatewayの定義
テスト用に、UserGateway
インターフェースを実装するモック版のGatewayを定義します。これはテスト専用のインフラストラクチャ層の具象実装と考えることができます。
// src/__tests__/mocks/userGateway.ts
import type { UserGateway } from '@/usecases/gateways/userGateway';
import type { User } from '@/domain/user'; // ドメインのUser型をインポート
export const mockUserGateway: UserGateway = {
async getUser(id: string): Promise<User> {
// テスト用のダミーデータを返す
// 実際にAPIを叩かず、テストに必要な特定の結果を返すようにする
return { id, name: `Mock User ${id}`, email: `user${id}@example.com` };
},
// 他のメソッドもテスト目的に応じてモック実装
};
テスト用Storeの生成と利用
Factory関数アプローチを使えば、テストコード内で簡単にモックGatewayを注入したStoreインスタンスを生成できます。
// src/__tests__/stores/user.spec.ts (Vitestでのテスト例)
import { describe, it, expect, beforeEach } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { createUserStore } from '@/stores/user'; // Factory関数をインポート
import { mockUserGateway } from '@/tests/mocks/userGateway'; // モックGatewayをインポート
import type { User } from '@/domain/user'; // User型をインポート
describe('userStore', () => {
beforeEach(() => {
// 各テストケースの前にPiniaのインスタンスを初期化し、アクティブにする
// これにより、テスト間でStoreの状態が混ざるのを防ぐ
setActivePinia(createPinia());
});
it('fetchUserアクションがユーザー情報を取得できるべき', async () => {
// Factory関数にモックGatewayを注入し、Storeインスタンスを生成
// Factory関数→defineStore関数→その実行
const userStore = createUserStore(mockUserGateway)(); // ()() となる理由: Factory関数がdefineStoreを返し、さらにその結果を呼び出すことでStoreインスタンスを得る
expect(userStore.user).toBeNull(); // 初期状態はnullのはず
await userStore.fetchUser('123');
// モックデータが正しくストアにセットされたかを確認
expect(userStore.user).toEqual({
id: '123',
name: 'Mock User 123',
email: 'user123@example.com',
});
});
// 他のテストケースも同様に、モックされた環境でPinia Store(UseCase)のロジックのみを検証できます
});
このように、Pinia Store(UseCase)は実際のAPIクライアントに依存しないため、ネットワーク接続なしで高速に単体テストを実行できます。これにより、テストの信頼性と開発スピードが格段に向上するのです。
📊 Clean Architecture + DIP 構造図
これまでの議論を図でまとめると、以下のようになります。矢印は依存の方向を示しています。
この図で最も重要なのは、UseCase (Pinia Store)
がAPI Client Gateway実装
に直接依存せず、その間のGateway Interface
という 「契約」を介している点です。これにより、ビジネスロジックの核が具体的なインフラストラクチャ(APIクライアントの実装詳細)から完全に分離され、依存の方向が逆転しています。これがクリーンアーキテクチャの強力な根拠の一つです。
✅ まとめ
第3回となる本記事では、PiniaをUseCase層として型安全に実装し、クリーンアーキテクチャの肝であるDIPをVue SPAで実践する方法を解説しました。
- PiniaをUseCase層に据えることで、ビジネスロジックを集中管理し、UIやインフラの実装詳細から分離できます。これはあなたのアプリケーションの 「頭脳」を独立させる ことに他なりません。
-
DIPを適用することで、Pinia Store(UseCase)が
Gateway Interface
という抽象に依存し、API Gateway
の具体的な実装から疎結合になります。これにより、コードの柔軟性と保守性が大幅に向上し、未来の技術変化にも強い設計が手に入ります。 -
依存性注入(DI)(特にFactory関数パターン)を実装することで、テスト時に
Gateway
のモックを簡単に差し替えられるようになり、ユニットテストの容易性が飛躍的に高まります。小規模プロジェクトならProvide/Inject
も手軽ですが、大規模開発や徹底したテストを重視するならFactory関数方式が断然おすすめです。 - これらの実践により、将来の仕様変更・APIクライアント刷新にも耐えられる柔軟な設計基盤になります。
この設計パターンは、中〜大規模なVueアプリケーションにおいて、長期的な開発効率とコード品質を保つための強力な基盤となるでしょう。ぜひ、あなたのプロジェクトに活用してみてください!
🔜 次回予告
これで、クリーンアーキテクチャにおけるUseCaseの実装は理解できたはずです。しかし、アプリケーションには認証情報のように、特定のUseCaseの範囲を超えて、全体で管理すべき重要な状態があります。次回の第4回では、この『例外的ApplicationState』の設計と、それを規律をもって扱うためのルール、そして認証基盤の構築について詳しく解説します。
✉️ おわりに
今回の記事に関して、ご質問やフィードバックがありましたら、Zennのコメント欄でぜひお気軽にお寄せください。皆さんのVue開発に少しでも役立てれば幸いです。
Discussion