T-CREATOR

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

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 層に分けて実装します。

  1. UI 層(ページ・コンポーネント): ユーザーインターフェースの表示とイベントハンドリングを担当
  2. ドメイン層(ビジネスロジック): ビジネスルールやデータ変換を担当
  3. インフラ層(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 層に影響せず、修正箇所を最小限に抑えられます。

次の表は、各層の責務と依存関係を整理したものです。

#責務依存先
1UI 層画面表示・イベントハンドリングドメイン層
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 つの機能から始めてみてはいかがでしょうか。

関連リンク