Nuxt クリーンアーキテクチャ実践:UI・ドメイン・インフラを composable で分離

Nuxt でアプリケーションを開発していると、コンポーネントやページファイルが肥大化し、ビジネスロジックと UI ロジックが混在してしまうことはありませんか。そんなとき、クリーンアーキテクチャの考え方を composable で実現すると、保守性が飛躍的に向上します。
本記事では、Nuxt における composable を活用した UI・ドメイン・インフラの 3 層分離を、実装例とともに詳しく解説します。アーキテクチャの設計から実際のコード配置まで、初心者の方でもすぐに実践できるように丁寧にご紹介しますね。
背景
クリーンアーキテクチャとは
クリーンアーキテクチャは、ソフトウェアの関心事を層(レイヤー)に分離し、依存関係を一方向に保つことで、保守性・テスト性・拡張性を高める設計思想です。代表的な層としては、UI 層・ドメイン層・インフラ層があります。
以下の図は、クリーンアーキテクチャにおける基本的なレイヤー構造と依存の方向を示しています。
mermaidflowchart TD
ui["UI 層<br/>(ページ・コンポーネント)"]
domain["ドメイン層<br/>(ビジネスロジック)"]
infra["インフラ層<br/>(API・DB アクセス)"]
ui -->|依存| domain
domain -->|依存| infra
図で理解できる要点:
- UI 層はドメイン層に依存し、ドメイン層はインフラ層に依存する一方向の流れ
- 各層が明確に分離されることで、変更の影響範囲を限定できる
Nuxt における composable の役割
Nuxt 3 では、Vue 3 の Composition API を活用した composable という仕組みがあります。composable は、状態管理やロジックを再利用可能な関数として切り出す手法です。
従来の Options API では、data・methods・computed が分散しがちでしたが、Composition API では関心事ごとにロジックをまとめられます。この特性を利用すると、クリーンアーキテクチャの各層を composable として実装できるのです。
なぜ層を分離するのか
層を分離する主な理由は以下の通りです。
- 保守性: ビジネスロジックが UI から独立しているため、UI の変更がロジックに影響しません
- テスト性: ドメイン層を単体でテストでき、モックを用いたテストが容易です
- 再利用性: 同じドメインロジックを複数のページやコンポーネントで共有できます
- 拡張性: 新機能追加時に既存コードへの影響を最小限に抑えられます
次の表は、層分離のメリットを整理したものです。
# | メリット | 説明 |
---|---|---|
1 | 保守性向上 | UI 変更がビジネスロジックに影響しない |
2 | テスト容易 | ドメイン層を独立してテスト可能 |
3 | 再利用可能 | 同じロジックを複数箇所で利用できる |
4 | 拡張性 | 新機能追加時の既存コードへの影響を抑える |
5 | チーム開発 | 役割分担が明確になり、並行開発がスムーズになります |
課題
コンポーネントにロジックが集中する問題
Nuxt でアプリケーションを構築する際、ページコンポーネントや各種コンポーネント内に API 呼び出し・データ変換・バリデーション・状態管理などのロジックをすべて記述してしまうケースが多く見られます。
このような実装では、以下の問題が発生します。
- ファイルが肥大化: 1 つのコンポーネントが数百行を超え、可読性が低下します
- 責務が不明確: UI の制御とビジネスロジックが混在し、どこに何があるか把握しづらい
- テストが困難: コンポーネント全体をマウントしないとロジックをテストできません
- 再利用が難しい: 同じロジックを別のページで使いたい場合、コピー&ペーストになりがちです
依存関係が複雑化するリスク
層を意識せずに実装を進めると、UI コンポーネントが直接 API クライアントを呼び出し、その結果をそのまま画面に表示する構造になります。この場合、API の仕様変更や外部サービスの変更が UI に直接影響し、修正箇所が広範囲に及ぶことがあります。
以下の図は、層分離がない場合の依存関係の複雑さを示しています。
mermaidflowchart LR
page1["ページA"]
page2["ページB"]
api["API クライアント"]
db[("データベース")]
page1 -->|直接呼び出し| api
page2 -->|直接呼び出し| api
api --> db
図で理解できる要点:
- 各ページが API クライアントを直接呼び出すため、API 変更時に全ページを修正する必要がある
- ビジネスロジックがページ間で重複しやすい
変更に弱い構造
外部 API の URL やレスポンス形式が変わると、それを利用しているすべてのコンポーネントを修正しなければなりません。また、ビジネスルールの変更(例:ユーザーのステータス判定ロジックの変更)も、各コンポーネントに散在しているため、漏れや不整合が発生しやすくなります。
このような構造では、以下のリスクが高まります。
# | リスク | 説明 |
---|---|---|
1 | 修正漏れ | 同じロジックが複数箇所にあると修正漏れが発生しやすい |
2 | 不整合 | ロジックのバージョンが混在し、動作が不安定になる |
3 | 影響範囲の拡大 | 小さな変更が広範囲に影響を及ぼす |
4 | リファクタリング困難 | 既存コードの整理が難しく、技術的負債が蓄積します |
解決策
クリーンアーキテクチャを composable で実現
Nuxt の composable を活用すると、クリーンアーキテクチャの各層を関数として分離できます。具体的には、以下の 3 層に分けて実装します。
- UI 層(ページ・コンポーネント): ユーザーインターフェースの表示とイベントハンドリングを担当
- ドメイン層(ビジネスロジック): ビジネスルールやデータ変換を担当
- インフラ層(API・DB アクセス): 外部サービスやデータソースとの通信を担当
次の図は、composable を用いた層分離の構造を示しています。
mermaidflowchart TD
ui["UI 層<br/>(ページ・コンポーネント)"]
domain["ドメイン層<br/>(useDomain)"]
infra["インフラ層<br/>(useRepository)"]
ui -->|利用| domain
domain -->|利用| infra
図で理解できる要点:
- UI 層はドメイン層の composable を呼び出すだけで、インフラ層を直接知らない
- ドメイン層はビジネスロジックに専念し、インフラ層を通じてデータを取得する
ディレクトリ構成の設計
Nuxt プロジェクトでは、composables
ディレクトリ配下に各層の composable を配置します。以下のようなディレクトリ構成が推奨されます。
markdowncomposables/
├── domain/
│ ├── useUserDomain.ts
│ └── useProductDomain.ts
├── repository/
│ ├── useUserRepository.ts
│ └── useProductRepository.ts
└── ui/
├── useUserUI.ts
└── useProductUI.ts
このように、機能ごと・層ごとにファイルを分けることで、関心事が明確になり、どこに何があるか一目でわかります。
依存の方向を一方向に保つ
クリーンアーキテクチャの重要な原則は、依存の方向を外側から内側へ一方向に保つことです。具体的には、以下のルールを守ります。
- UI 層はドメイン層に依存する(インフラ層は知らない)
- ドメイン層はインフラ層に依存する(UI 層は知らない)
- インフラ層は他の層に依存しない(独立している)
この原則により、インフラ層の変更(例:API エンドポイントの変更)がドメイン層や UI 層に影響せず、修正箇所を最小限に抑えられます。
次の表は、各層の責務と依存関係を整理したものです。
# | 層 | 責務 | 依存先 |
---|---|---|---|
1 | UI 層 | 画面表示・イベントハンドリング | ドメイン層 |
2 | ドメイン層 | ビジネスロジック・データ変換 | インフラ層 |
3 | インフラ層 | API 呼び出し・DB アクセス | なし(独立) |
各層の実装方針
インフラ層(Repository)
インフラ層は、外部 API や DB との通信を担当します。useFetch
や $fetch
を用いて実際の HTTP リクエストを行い、生データを返します。
この層では、ビジネスロジックを含めず、純粋にデータの取得・送信のみを行いましょう。
ドメイン層(Domain)
ドメイン層は、インフラ層から取得したデータを加工し、ビジネスルールを適用します。例えば、ユーザーのステータス判定や、商品の在庫計算などをここで行います。
UI 層はこのドメイン層の composable を呼び出すだけで、ビジネスロジックを意識する必要がありません。
UI 層(ページ・コンポーネント)
UI 層は、ドメイン層の composable を呼び出し、取得したデータをテンプレートにバインドします。イベントハンドラーもドメイン層のメソッドを呼び出すだけで、ロジックは一切記述しません。
この分離により、UI の変更がビジネスロジックに影響しなくなります。
具体例
インフラ層:API 呼び出しの実装
まず、インフラ層の composable を実装します。ここでは、ユーザー情報を取得する例を示します。
インフラ層の composable ファイル作成
以下のファイルを作成してください。
ファイル: composables/repository/useUserRepository.ts
このファイルでは、ユーザー情報を取得する fetchUsers
関数を提供します。実際の API エンドポイントにリクエストを送り、結果を返す役割を担います。
typescript// composables/repository/useUserRepository.ts
/**
* ユーザー情報のリポジトリ
* API からユーザーデータを取得する責務を持つ
*/
export const useUserRepository = () => {
/**
* ユーザー一覧を API から取得
* @returns ユーザー配列
*/
const fetchUsers = async () => {
const response = await $fetch('/api/users');
return response;
};
return {
fetchUsers,
};
};
ポイント:
$fetch
を使って API エンドポイント/api/users
にリクエストを送信します- ビジネスロジックは含めず、取得したデータをそのまま返します
- コメントを多用し、関数の役割を明確にしています
ドメイン層:ビジネスロジックの実装
次に、ドメイン層の composable を実装します。ここでは、インフラ層から取得したユーザー情報を加工し、ビジネスルールを適用します。
ドメイン層の composable ファイル作成
以下のファイルを作成してください。
ファイル: composables/domain/useUserDomain.ts
このファイルでは、ユーザー情報を取得し、アクティブなユーザーのみをフィルタリングするロジックを提供します。
typescript// composables/domain/useUserDomain.ts
import { useUserRepository } from '~/composables/repository/useUserRepository';
/**
* ユーザーの型定義
*/
interface User {
id: number;
name: string;
status: 'active' | 'inactive';
}
ポイント:
- インフラ層の
useUserRepository
をインポートします - ユーザーの型を定義し、データ構造を明確にします
ビジネスロジックの実装
続いて、ビジネスロジックを実装します。
typescript/**
* ユーザードメインロジック
*/
export const useUserDomain = () => {
const repository = useUserRepository();
/**
* アクティブなユーザーのみを取得
* @returns アクティブユーザー配列
*/
const getActiveUsers = async (): Promise<User[]> => {
// インフラ層からユーザー一覧を取得
const users = await repository.fetchUsers();
// ビジネスルール: ステータスが 'active' のユーザーのみ抽出
const activeUsers = users.filter(
(user: User) => user.status === 'active'
);
return activeUsers;
};
return {
getActiveUsers,
};
};
ポイント:
useUserRepository
を呼び出し、API からデータを取得します- ビジネスルール(アクティブユーザーのフィルタリング)をここで実装します
- UI 層はこの
getActiveUsers
を呼び出すだけで、フィルタリングロジックを意識する必要がありません
UI 層:ページでの利用
最後に、UI 層であるページコンポーネントでドメイン層の composable を利用します。
ページコンポーネントの作成
以下のファイルを作成してください。
ファイル: pages/users.vue
このページでは、アクティブなユーザー一覧を表示します。
vue<script setup lang="ts">
// composables/domain/useUserDomain からドメインロジックをインポート
import { useUserDomain } from '~/composables/domain/useUserDomain';
// ドメイン層の composable を呼び出し
const { getActiveUsers } = useUserDomain();
// アクティブユーザーを取得
const users = ref([]);
onMounted(async () => {
// ページマウント時にアクティブユーザーを取得
users.value = await getActiveUsers();
});
</script>
ポイント:
- ドメイン層の
useUserDomain
をインポートします getActiveUsers
を呼び出すだけで、ビジネスロジックを実行できます- UI 層には API 呼び出しやフィルタリングのロジックが一切ありません
テンプレートの実装
続いて、テンプレート部分を実装します。
vue<template>
<div>
<h1>アクティブユーザー一覧</h1>
<ul>
<!-- 取得したユーザーをリスト表示 -->
<li v-for="user in users" :key="user.id">
{{ user.name }} (ID: {{ user.id }})
</li>
</ul>
</div>
</template>
ポイント:
- シンプルなテンプレートで、ユーザー一覧を表示します
- UI の変更(デザインやレイアウト)がビジネスロジックに影響しません
データフローの全体像
ここまでの実装で、以下のようなデータフローが実現されます。
mermaidsequenceDiagram
participant UI as UI 層<br/>(pages/users.vue)
participant Domain as ドメイン層<br/>(useUserDomain)
participant Infra as インフラ層<br/>(useUserRepository)
participant API as API サーバー
UI ->>+ Domain: getActiveUsers() 呼び出し
Domain ->>+ Infra: fetchUsers() 呼び出し
Infra ->>+ API: GET /api/users
API -->>- Infra: ユーザーデータ返却
Infra -->>- Domain: 生データ返却
Domain ->>Domain: ステータスでフィルタリング
Domain -->>- UI: アクティブユーザー返却
UI ->>UI: 画面に表示
図で理解できる要点:
- UI 層はドメイン層を呼び出すだけで、API の存在を知らない
- ドメイン層はインフラ層を利用してデータを取得し、ビジネスロジックを適用
- インフラ層は API との通信のみに専念
テストの容易性
このように層を分離すると、ドメイン層を独立してテストできます。以下は、ドメイン層のテストコード例です。
テストファイルの作成
以下のファイルを作成してください。
ファイル: tests/domain/useUserDomain.test.ts
このテストでは、ドメイン層のロジックを単体でテストします。
typescript// tests/domain/useUserDomain.test.ts
import { describe, it, expect, vi } from 'vitest';
import { useUserDomain } from '~/composables/domain/useUserDomain';
import { useUserRepository } from '~/composables/repository/useUserRepository';
// インフラ層をモック化
vi.mock('~/composables/repository/useUserRepository');
ポイント:
- vitest を使ってテストを記述します
useUserRepository
をモック化し、実際の API を呼び出さずにテストします
テストケースの実装
続いて、具体的なテストケースを実装します。
typescriptdescribe('useUserDomain', () => {
it('アクティブなユーザーのみを返す', async () => {
// モックデータを準備
const mockUsers = [
{ id: 1, name: 'Alice', status: 'active' },
{ id: 2, name: 'Bob', status: 'inactive' },
{ id: 3, name: 'Charlie', status: 'active' },
];
// useUserRepository のモック実装
vi.mocked(useUserRepository).mockReturnValue({
fetchUsers: vi.fn().mockResolvedValue(mockUsers),
});
const { getActiveUsers } = useUserDomain();
const result = await getActiveUsers();
// 期待値: アクティブなユーザーのみ
expect(result).toEqual([
{ id: 1, name: 'Alice', status: 'active' },
{ id: 3, name: 'Charlie', status: 'active' },
]);
});
});
ポイント:
- モックデータを用意し、
fetchUsers
の返却値を制御します - ドメイン層の
getActiveUsers
を呼び出し、結果を検証します - 実際の API を呼び出さずに、ロジックのみをテストできます
拡張性の向上
新しい機能を追加する場合も、層ごとに実装を追加するだけで対応できます。
例:ユーザー検索機能の追加
ユーザーを名前で検索する機能を追加する場合、以下の手順で実装します。
ステップ 1: ドメイン層に検索ロジックを追加
typescript// composables/domain/useUserDomain.ts に追加
/**
* 名前でユーザーを検索
* @param query 検索キーワード
* @returns マッチしたユーザー配列
*/
const searchUsersByName = async (
query: string
): Promise<User[]> => {
const users = await repository.fetchUsers();
return users.filter((user: User) =>
user.name.toLowerCase().includes(query.toLowerCase())
);
};
return {
getActiveUsers,
searchUsersByName, // 新しく追加
};
ポイント:
- 既存の
getActiveUsers
に影響を与えず、新機能を追加できます - インフラ層の変更は不要です
ステップ 2: UI 層で検索機能を利用
vue<script setup lang="ts">
const { searchUsersByName } = useUserDomain();
const query = ref('');
const searchResults = ref([]);
const handleSearch = async () => {
searchResults.value = await searchUsersByName(
query.value
);
};
</script>
<template>
<div>
<input v-model="query" placeholder="名前で検索" />
<button @click="handleSearch">検索</button>
<ul>
<li v-for="user in searchResults" :key="user.id">
{{ user.name }}
</li>
</ul>
</div>
</template>
ポイント:
- UI 層は新しいドメインロジックを呼び出すだけで、検索機能を実現できます
- ビジネスロジックが UI に漏れず、保守性が保たれます
エラー処理の実装
実際のアプリケーションでは、エラー処理も重要です。各層でのエラー処理方法を示します。
インフラ層でのエラーハンドリング
typescript// composables/repository/useUserRepository.ts
const fetchUsers = async () => {
try {
const response = await $fetch('/api/users');
return response;
} catch (error) {
// エラーログを出力
console.error('Error fetching users:', error);
throw new Error('ユーザー情報の取得に失敗しました');
}
};
ポイント:
- API 呼び出しが失敗した場合、エラーをキャッチして適切なメッセージを投げます
- ドメイン層や UI 層でエラーをハンドリングできるようにします
ドメイン層でのエラーハンドリング
typescript// composables/domain/useUserDomain.ts
const getActiveUsers = async (): Promise<User[]> => {
try {
const users = await repository.fetchUsers();
return users.filter(
(user: User) => user.status === 'active'
);
} catch (error) {
// エラーを UI 層に通知
console.error('ドメイン層でエラー:', error);
return []; // 空配列を返す、またはエラーを再スロー
}
};
ポイント:
- ドメイン層でもエラーをキャッチし、適切な処理を行います
- 必要に応じて UI 層にエラーを通知します
UI 層でのエラー表示
vue<script setup lang="ts">
const { getActiveUsers } = useUserDomain();
const users = ref([]);
const errorMessage = ref('');
onMounted(async () => {
try {
users.value = await getActiveUsers();
} catch (error) {
errorMessage.value = 'ユーザー情報の取得に失敗しました';
}
});
</script>
<template>
<div>
<p v-if="errorMessage" class="error">
{{ errorMessage }}
</p>
<ul v-else>
<li v-for="user in users" :key="user.id">
{{ user.name }}
</li>
</ul>
</div>
</template>
ポイント:
- UI 層でエラーメッセージを表示し、ユーザーにフィードバックを提供します
- エラー処理もクリーンに分離されています
まとめ
Nuxt でクリーンアーキテクチャを実践するには、composable を活用して UI 層・ドメイン層・インフラ層を明確に分離することが鍵となります。この分離により、保守性・テスト性・拡張性が飛躍的に向上し、チーム開発もスムーズになります。
本記事で紹介した実装パターンを参考に、ぜひご自身のプロジェクトで試してみてください。最初は慣れないかもしれませんが、慣れてくるとコードの見通しが良くなり、開発効率が格段に上がることを実感できるでしょう。
クリーンアーキテクチャは一度に完璧に導入する必要はありません。既存のプロジェクトでも、新機能から少しずつ適用していくことで、徐々に構造を改善できます。まずは 1 つの機能から始めてみてはいかがでしょうか。
関連リンク
- article
Nuxt クリーンアーキテクチャ実践:UI・ドメイン・インフラを composable で分離
- article
Nuxt nuxi コマンド速見表:プロジェクト作成からモジュール公開まで
- article
Nuxt を macOS + yarn で最短構築:ESLint/Prettier/TS 設定まで一気通貫
- article
Nuxt と Next.js を徹底比較:開発体験・レンダリング・エコシステムの違い
- article
Nuxt Hydration mismatch を根絶:原因パターン別チェックリストと修正手順
- article
Nuxt レンダリング戦略を一気に把握:SSR・SSG・ISR・CSR・Edge の最適解
- article
Obsidian Vault 設計の教科書:個人用とチーム用を両立する情報区画
- article
Claude Code で発生する API Error: 401·{"type":"error", ...} Please run /login の対処法
- article
Nuxt クリーンアーキテクチャ実践:UI・ドメイン・インフラを composable で分離
- article
オフラインファースト設計:Zustand で楽観的 UI とロールバックを実現
- article
Nginx API ゲートウェイ設計:auth_request/サーキットブレーカ/レート制限の組み合わせ
- article
CI/CD で更新を自動化:GitHub Actions と WordPress の安全デプロイ
- blog
iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来