T-CREATOR

SolidJS クリーンアーキテクチャ実践:UI・状態・副作用を厳密に分離する

SolidJS クリーンアーキテクチャ実践:UI・状態・副作用を厳密に分離する

SolidJS はリアクティブシステムの強力さと、きめ細かな更新制御により注目を集めています。しかし、アプリケーションが成長するにつれて、UI ロジック・状態管理・副作用処理が混在し、保守性やテストのしやすさが損なわれることも少なくありません。

そこで本記事では、クリーンアーキテクチャの原則を SolidJS に適用し、UI・状態・副作用を厳密に分離する実践手法をご紹介します。レイヤーごとの責務を明確化し、依存関係を一方向に保つことで、変更に強く、拡張しやすいコードベースを実現できるでしょう。

背景

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

クリーンアーキテクチャは、Robert C. Martin(Uncle Bob)が提唱したソフトウェア設計の原則です。ビジネスロジックをフレームワークや UI、データベースといった外部要素から独立させることで、長期的な保守性と変更容易性を確保します。

以下の図は、クリーンアーキテクチャの基本構造を示しています。

mermaidflowchart TB
  subgraph presentation["プレゼンテーション層"]
    ui["UI コンポーネント"]
  end
  subgraph application["アプリケーション層"]
    usecase["ユースケース"]
  end
  subgraph domain["ドメイン層"]
    entity["エンティティ"]
  end
  subgraph infrastructure["インフラ層"]
    api["API Client"]
    storage["Storage"]
  end

  ui -->|呼び出し| usecase
  usecase -->|操作| entity
  usecase -->|依存注入| api
  usecase -->|依存注入| storage

この図から分かるように、中心にあるドメイン層(エンティティ)は外部に依存せず、アプリケーション層(ユースケース)を介してプレゼンテーション層やインフラ層と連携します。依存の方向は必ず内側(ドメイン)に向かい、外側の層は内側の層のインターフェースを利用します。

SolidJS の特性とクリーンアーキテクチャの親和性

SolidJS はリアクティブプリミティブ(Signal、Effect、Memo)を用いて、細粒度の更新を実現するライブラリです。仮想 DOM を持たないため、パフォーマンスに優れ、状態管理も直感的に記述できます。

一方で、この柔軟性は「どこに何を書いても動く」という状況を生み出しやすく、コンポーネント内にビジネスロジックや副作用が混在しがちです。クリーンアーキテクチャを適用することで、責務を明確に分離し、テストしやすく、変更に強い設計を実現できるでしょう。

課題

コンポーネントへのロジック集中

SolidJS では createSignalcreateEffect を用いてコンポーネント内で状態と副作用を管理できます。しかし、この便利さゆえに、以下のような問題が発生しやすくなります。

mermaidflowchart LR
  component["コンポーネント"]
  component -->|含む| ui["UI ロジック"]
  component -->|含む| state["状態管理"]
  component -->|含む| effect["副作用処理"]
  component -->|含む| business["ビジネスロジック"]
  component -->|含む| api["API 呼び出し"]

この図が示すように、コンポーネントが複数の責務を抱え込むことで、以下の課題が顕在化します。

  1. テストの困難さ: UI と副作用が密結合しているため、単体テストでモックを用意するのが難しい
  2. 再利用性の低下: ロジックがコンポーネントに埋め込まれ、他の画面で同じロジックを使えない
  3. 変更の影響範囲拡大: ビジネスルールの変更が UI にも波及し、修正箇所が増える
  4. 依存関係の複雑化: コンポーネントが直接 API クライアントやストレージに依存し、テストや差し替えが困難

状態と副作用の境界不明瞭

SolidJS の createEffect は非常に強力ですが、コンポーネント内で無秩序に使うと、どの Effect がどの Signal に依存しているか把握しづらくなります。また、副作用をコンポーネント外に切り出す際の指針が不明確だと、リファクタリングも困難です。

解決策

レイヤー分割の基本方針

クリーンアーキテクチャを SolidJS に適用する際、以下の 4 つのレイヤーに分割します。

#レイヤー責務SolidJS における実装例
1ドメイン層ビジネスルールとエンティティ純粋な TypeScript クラス・型定義
2アプリケーション層ユースケース・状態管理カスタムフック(createXxx)、Store
3インフラ層外部通信・永続化API Client、LocalStorage ラッパー
4プレゼンテーション層UI コンポーネントSolidJS コンポーネント(JSX)

依存関係は必ず 外側から内側へ 向かい、内側の層は外側の層を知りません。これにより、ドメイン層はフレームワークや UI に依存せず、純粋なロジックとして独立します。

以下の図は、各レイヤーの依存関係を示しています。

mermaidflowchart TB
  presentation["プレゼンテーション層<br/>(UI コンポーネント)"]
  application["アプリケーション層<br/>(ユースケース・State)"]
  domain["ドメイン層<br/>(エンティティ・ビジネスルール)"]
  infrastructure["インフラ層<br/>(API・Storage)"]

  presentation -->|依存| application
  application -->|依存| domain
  application -.->|依存注入で利用| infrastructure
  infrastructure -.->|実装| domain

この図から、プレゼンテーション層はアプリケーション層のみを知り、アプリケーション層はドメイン層のみを知ることが分かります。インフラ層はドメイン層のインターフェースを実装し、アプリケーション層に注入されます。

各レイヤーの役割と実装方針

1. ドメイン層

ビジネスロジックとエンティティを定義します。SolidJS のリアクティブシステムには一切依存しません。

責務:

  • エンティティ(データ構造)の定義
  • ビジネスルールの実装
  • リポジトリインターフェースの定義

禁止事項:

  • createSignalcreateEffect の使用
  • UI コンポーネントへの依存
  • 具体的な API クライアントやストレージへの依存

2. アプリケーション層

ユースケースを実装し、ドメイン層とインフラ層を組み合わせて、アプリケーション固有の状態管理を行います。

責務:

  • ユースケースの実装(カスタムフック形式)
  • 状態管理(Store、Signal)
  • ドメイン層とインフラ層の橋渡し

注意点:

  • UI に関するロジックは含めない
  • 副作用は最小限に留め、インフラ層に委譲する

3. インフラ層

外部システムとの通信や永続化を担当します。ドメイン層で定義したインターフェースを実装します。

責務:

  • API 呼び出し
  • LocalStorage、IndexedDB へのアクセス
  • リポジトリインターフェースの実装

注意点:

  • ビジネスロジックは含めない
  • データの取得・保存のみに専念する

4. プレゼンテーション層

ユーザーに表示する UI コンポーネントを実装します。アプリケーション層のカスタムフックを利用して、状態とユースケースにアクセスします。

責務:

  • JSX による UI 記述
  • ユーザーイベントのハンドリング
  • アプリケーション層へのデータ受け渡し

禁止事項:

  • ビジネスロジックの実装
  • 直接的な API 呼び出し
  • 複雑な状態管理(アプリケーション層に委譲)

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

インフラ層がドメイン層に依存しないよう、依存性逆転の原則を適用します。ドメイン層でインターフェース(抽象)を定義し、インフラ層がそれを実装することで、ドメイン層は具体的な実装を知らずに済みます。

mermaidflowchart LR
  usecase["ユースケース<br/>(アプリケーション層)"]
  repo_interface["リポジトリ<br/>インターフェース<br/>(ドメイン層)"]
  repo_impl["リポジトリ<br/>実装<br/>(インフラ層)"]

  usecase -->|依存| repo_interface
  repo_impl -.->|実装| repo_interface
  usecase -.->|注入| repo_impl

この図から、ユースケースはリポジトリインターフェースに依存し、具体的な実装はアプリケーション起動時に注入されることが分かります。これにより、テスト時にモックを簡単に差し替えられます。

具体例

ここでは、ユーザー管理機能を例に、各レイヤーの実装を段階的に示します。

ステップ 1: ドメイン層の実装

エンティティの定義

ドメイン層では、アプリケーションの中核となるデータ構造を定義します。

typescript// domain/entities/User.ts

/**
 * ユーザーエンティティ
 * ビジネスロジックの中核となるデータ構造を定義
 */
export class User {
  constructor(
    public readonly id: string,
    public readonly name: string,
    public readonly email: string,
    public readonly createdAt: Date
  ) {}

  /**
   * ユーザー名が有効かどうかを検証
   */
  isValidName(): boolean {
    return this.name.length >= 2 && this.name.length <= 50;
  }

  /**
   * メールアドレスが有効かどうかを検証
   */
  isValidEmail(): boolean {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return emailRegex.test(this.email);
  }
}

このコードでは、User クラスに基本的なバリデーションロジックを含めています。外部ライブラリや SolidJS のリアクティブシステムには一切依存していません。

リポジトリインターフェースの定義

次に、ユーザーデータの取得・保存を抽象化したインターフェースを定義します。

typescript// domain/repositories/UserRepository.ts

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

/**
 * ユーザーリポジトリインターフェース
 * データの取得・保存方法を抽象化し、具体的な実装は隠蔽
 */
export interface UserRepository {
  /**
   * すべてのユーザーを取得
   */
  findAll(): Promise<User[]>;

  /**
   * ID でユーザーを取得
   */
  findById(id: string): Promise<User | null>;

  /**
   * ユーザーを作成
   */
  create(
    user: Omit<User, 'id' | 'createdAt'>
  ): Promise<User>;

  /**
   * ユーザーを削除
   */
  delete(id: string): Promise<void>;
}

このインターフェースにより、アプリケーション層は具体的な API 実装を知らずに、ユーザーデータを操作できます。

ステップ 2: インフラ層の実装

API クライアントの実装

インフラ層では、ドメイン層で定義したインターフェースを実装します。

typescript// infrastructure/api/UserApiClient.ts

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

/**
 * ユーザー API クライアント
 * リポジトリインターフェースを実装し、実際の HTTP 通信を行う
 */
export class UserApiClient implements UserRepository {
  constructor(private readonly baseUrl: string) {}

  async findAll(): Promise<User[]> {
    const response = await fetch(`${this.baseUrl}/users`);
    if (!response.ok) {
      throw new Error('Failed to fetch users');
    }
    const data = await response.json();
    return data.map(
      (item: any) =>
        new User(
          item.id,
          item.name,
          item.email,
          new Date(item.createdAt)
        )
    );
  }

  async findById(id: string): Promise<User | null> {
    const response = await fetch(
      `${this.baseUrl}/users/${id}`
    );
    if (response.status === 404) {
      return null;
    }
    if (!response.ok) {
      throw new Error('Failed to fetch user');
    }
    const item = await response.json();
    return new User(
      item.id,
      item.name,
      item.email,
      new Date(item.createdAt)
    );
  }

  async create(
    user: Omit<User, 'id' | 'createdAt'>
  ): Promise<User> {
    const response = await fetch(`${this.baseUrl}/users`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(user),
    });
    if (!response.ok) {
      throw new Error('Failed to create user');
    }
    const item = await response.json();
    return new User(
      item.id,
      item.name,
      item.email,
      new Date(item.createdAt)
    );
  }

  async delete(id: string): Promise<void> {
    const response = await fetch(
      `${this.baseUrl}/users/${id}`,
      {
        method: 'DELETE',
      }
    );
    if (!response.ok) {
      throw new Error('Failed to delete user');
    }
  }
}

このコードは、HTTP 通信を行う具体的な実装です。ドメイン層のインターフェースを実装しているため、アプリケーション層からは抽象化されたメソッドを通じてアクセスできます。

ステップ 3: アプリケーション層の実装

ユースケースの実装(カスタムフック)

アプリケーション層では、ユースケースをカスタムフック形式で実装します。SolidJS の createSignalcreateResource を使用して、状態管理と副作用を制御します。

typescript// application/usecases/useUserManagement.ts

import { createSignal, createResource } from 'solid-js';
import { UserRepository } from '../../domain/repositories/UserRepository';
import { User } from '../../domain/entities/User';

/**
 * ユーザー管理ユースケース
 * リポジトリを注入し、状態管理と副作用を統合
 */
export function useUserManagement(
  repository: UserRepository
) {
  // ユーザー一覧の取得(リソース形式)
  const [users, { mutate, refetch }] = createResource<
    User[]
  >(async () => {
    return await repository.findAll();
  });

  // ローディング状態
  const [isCreating, setIsCreating] = createSignal(false);
  const [isDeleting, setIsDeleting] = createSignal(false);

  /**
   * ユーザーを作成
   */
  const createUser = async (
    name: string,
    email: string
  ) => {
    setIsCreating(true);
    try {
      const newUser = await repository.create({
        name,
        email,
      });
      // 既存のユーザーリストに新しいユーザーを追加
      mutate((prev) =>
        prev ? [...prev, newUser] : [newUser]
      );
      return newUser;
    } catch (error) {
      console.error('Failed to create user:', error);
      throw error;
    } finally {
      setIsCreating(false);
    }
  };

  /**
   * ユーザーを削除
   */
  const deleteUser = async (id: string) => {
    setIsDeleting(true);
    try {
      await repository.delete(id);
      // 既存のユーザーリストから削除
      mutate((prev) =>
        prev ? prev.filter((u) => u.id !== id) : []
      );
    } catch (error) {
      console.error('Failed to delete user:', error);
      throw error;
    } finally {
      setIsDeleting(false);
    }
  };

  return {
    users,
    isCreating,
    isDeleting,
    createUser,
    deleteUser,
    refetch,
  };
}

このカスタムフックは、リポジトリを注入され、ユーザー一覧の取得・作成・削除を行います。createResource を用いて非同期データ取得を行い、createSignal でローディング状態を管理しています。

UI コンポーネントはこのフックを呼び出すだけで、ビジネスロジックや副作用を意識せずに済みます。

ステップ 4: プレゼンテーション層の実装

UI コンポーネントの実装

プレゼンテーション層では、アプリケーション層のカスタムフックを利用して、UI を構築します。

typescript// presentation/components/UserList.tsx

import { Component, For, createSignal } from 'solid-js';
import { useUserManagement } from '../../application/usecases/useUserManagement';
import { UserApiClient } from '../../infrastructure/api/UserApiClient';

/**
 * ユーザー一覧コンポーネント
 * アプリケーション層のユースケースを利用し、UI のみに専念
 */
const UserList: Component = () => {
  // リポジトリの注入(実際のアプリでは DI コンテナを使用することが多い)
  const repository = new UserApiClient(
    'https://api.example.com'
  );
  const {
    users,
    isCreating,
    isDeleting,
    createUser,
    deleteUser,
    refetch,
  } = useUserManagement(repository);

  const [name, setName] = createSignal('');
  const [email, setEmail] = createSignal('');

  const handleCreate = async () => {
    try {
      await createUser(name(), email());
      setName('');
      setEmail('');
    } catch (error) {
      alert('ユーザーの作成に失敗しました');
    }
  };

  const handleDelete = async (id: string) => {
    if (!confirm('本当に削除しますか?')) return;
    try {
      await deleteUser(id);
    } catch (error) {
      alert('ユーザーの削除に失敗しました');
    }
  };

  return (
    <div>
      <h1>ユーザー管理</h1>

      {/* ユーザー作成フォーム */}
      <div>
        <input
          type='text'
          placeholder='名前'
          value={name()}
          onInput={(e) => setName(e.currentTarget.value)}
        />
        <input
          type='email'
          placeholder='メールアドレス'
          value={email()}
          onInput={(e) => setEmail(e.currentTarget.value)}
        />
        <button
          onClick={handleCreate}
          disabled={isCreating()}
        >
          {isCreating() ? '作成中...' : 'ユーザーを作成'}
        </button>
      </div>

      {/* ユーザー一覧 */}
      <ul>
        <For each={users()}>
          {(user) => (
            <li>
              {user.name} ({user.email})
              <button
                onClick={() => handleDelete(user.id)}
                disabled={isDeleting()}
              >
                削除
              </button>
            </li>
          )}
        </For>
      </ul>
    </div>
  );
};

export default UserList;

このコンポーネントは、useUserManagement フックを呼び出して、状態とメソッドを取得しています。UI ロジックのみに専念し、ビジネスロジックや API 呼び出しは一切含まれていません。

ステップ 5: 依存注入の改善

実際のアプリケーションでは、コンポーネント内でリポジトリを直接インスタンス化するのではなく、依存注入コンテナを使用します。

typescript// infrastructure/di/container.ts

import { UserApiClient } from '../api/UserApiClient';
import { UserRepository } from '../../domain/repositories/UserRepository';

/**
 * 依存注入コンテナ
 * リポジトリの実装を一元管理し、テストや差し替えを容易にする
 */
export const container = {
  userRepository: new UserApiClient(
    'https://api.example.com'
  ) as UserRepository,
};

コンポーネントでは、このコンテナから取得します。

typescript// presentation/components/UserList.tsx(改善版)

import { Component, For, createSignal } from 'solid-js';
import { useUserManagement } from '../../application/usecases/useUserManagement';
import { container } from '../../infrastructure/di/container';

const UserList: Component = () => {
  // コンテナからリポジトリを取得
  const {
    users,
    isCreating,
    isDeleting,
    createUser,
    deleteUser,
    refetch,
  } = useUserManagement(container.userRepository);

  const [name, setName] = createSignal('');
  const [email, setEmail] = createSignal('');

  const handleCreate = async () => {
    try {
      await createUser(name(), email());
      setName('');
      setEmail('');
    } catch (error) {
      alert('ユーザーの作成に失敗しました');
    }
  };

  const handleDelete = async (id: string) => {
    if (!confirm('本当に削除しますか?')) return;
    try {
      await deleteUser(id);
    } catch (error) {
      alert('ユーザーの削除に失敗しました');
    }
  };

  return (
    <div>
      <h1>ユーザー管理</h1>

      <div>
        <input
          type='text'
          placeholder='名前'
          value={name()}
          onInput={(e) => setName(e.currentTarget.value)}
        />
        <input
          type='email'
          placeholder='メールアドレス'
          value={email()}
          onInput={(e) => setEmail(e.currentTarget.value)}
        />
        <button
          onClick={handleCreate}
          disabled={isCreating()}
        >
          {isCreating() ? '作成中...' : 'ユーザーを作成'}
        </button>
      </div>

      <ul>
        <For each={users()}>
          {(user) => (
            <li>
              {user.name} ({user.email})
              <button
                onClick={() => handleDelete(user.id)}
                disabled={isDeleting()}
              >
                削除
              </button>
            </li>
          )}
        </For>
      </ul>
    </div>
  );
};

export default UserList;

この改善により、テスト時にはモックリポジトリに差し替えるだけで済みます。

ステップ 6: テストの実装

クリーンアーキテクチャの最大の利点は、各レイヤーを独立してテストできることです。

ドメイン層のテスト

typescript// domain/entities/User.test.ts

import { describe, it, expect } from 'vitest';
import { User } from './User';

describe('User Entity', () => {
  it('有効な名前を検証できる', () => {
    const user = new User(
      '1',
      'John Doe',
      'john@example.com',
      new Date()
    );
    expect(user.isValidName()).toBe(true);
  });

  it('短すぎる名前を検証できる', () => {
    const user = new User(
      '1',
      'J',
      'john@example.com',
      new Date()
    );
    expect(user.isValidName()).toBe(false);
  });

  it('有効なメールアドレスを検証できる', () => {
    const user = new User(
      '1',
      'John Doe',
      'john@example.com',
      new Date()
    );
    expect(user.isValidEmail()).toBe(true);
  });

  it('無効なメールアドレスを検証できる', () => {
    const user = new User(
      '1',
      'John Doe',
      'invalid-email',
      new Date()
    );
    expect(user.isValidEmail()).toBe(false);
  });
});

ドメイン層は純粋な TypeScript クラスなので、モックやスタブなしでテストできます。

アプリケーション層のテスト(モックリポジトリを使用)

typescript// application/usecases/useUserManagement.test.ts

import { describe, it, expect, vi } from 'vitest';
import {
  renderHook,
  waitFor,
} from '@solidjs/testing-library';
import { useUserManagement } from './useUserManagement';
import { UserRepository } from '../../domain/repositories/UserRepository';
import { User } from '../../domain/entities/User';

describe('useUserManagement', () => {
  it('ユーザー一覧を取得できる', async () => {
    // モックリポジトリを作成
    const mockRepository: UserRepository = {
      findAll: vi
        .fn()
        .mockResolvedValue([
          new User(
            '1',
            'Alice',
            'alice@example.com',
            new Date()
          ),
          new User(
            '2',
            'Bob',
            'bob@example.com',
            new Date()
          ),
        ]),
      findById: vi.fn(),
      create: vi.fn(),
      delete: vi.fn(),
    };

    const { result } = renderHook(() =>
      useUserManagement(mockRepository)
    );

    await waitFor(() => {
      expect(result.users()).toHaveLength(2);
      expect(result.users()?.[0].name).toBe('Alice');
    });
  });

  it('ユーザーを作成できる', async () => {
    const newUser = new User(
      '3',
      'Charlie',
      'charlie@example.com',
      new Date()
    );
    const mockRepository: UserRepository = {
      findAll: vi.fn().mockResolvedValue([]),
      findById: vi.fn(),
      create: vi.fn().mockResolvedValue(newUser),
      delete: vi.fn(),
    };

    const { result } = renderHook(() =>
      useUserManagement(mockRepository)
    );

    await result.createUser(
      'Charlie',
      'charlie@example.com'
    );

    expect(mockRepository.create).toHaveBeenCalledWith({
      name: 'Charlie',
      email: 'charlie@example.com',
    });
  });
});

このテストでは、モックリポジトリを注入することで、実際の API 呼び出しを行わずにユースケースをテストしています。

ディレクトリ構成の全体像

最後に、プロジェクト全体のディレクトリ構成を示します。

bashsrc/
├── domain/                    # ドメイン層
│   ├── entities/
│   │   ├── User.ts
│   │   └── User.test.ts
│   └── repositories/
│       └── UserRepository.ts
├── application/               # アプリケーション層
│   └── usecases/
│       ├── useUserManagement.ts
│       └── useUserManagement.test.ts
├── infrastructure/            # インフラ層
│   ├── api/
│   │   └── UserApiClient.ts
│   └── di/
│       └── container.ts
└── presentation/              # プレゼンテーション層
    └── components/
        └── UserList.tsx

この構成により、各レイヤーの責務が明確になり、変更の影響範囲が限定されます。

まとめ

本記事では、SolidJS にクリーンアーキテクチャを適用し、UI・状態・副作用を厳密に分離する実践手法をご紹介しました。

重要なポイント:

  • レイヤー分割: ドメイン、アプリケーション、インフラ、プレゼンテーションの 4 層に分離することで、責務を明確化
  • 依存関係の方向: 外側から内側へ一方向に依存し、ドメイン層はフレームワークに依存しない
  • 依存性逆転の原則: インターフェースを定義し、具体的な実装を注入することでテストしやすさを確保
  • カスタムフックの活用: アプリケーション層でユースケースをカスタムフック形式で実装し、状態管理と副作用を統合
  • テストのしやすさ: 各レイヤーを独立してテストでき、モックやスタブの導入が容易

クリーンアーキテクチャは初期コストがかかるものの、長期的にはコードの保守性、拡張性、テストのしやすさを大幅に向上させます。SolidJS のリアクティブシステムと組み合わせることで、パフォーマンスと設計品質の両立が実現できるでしょう。

ぜひ、実際のプロジェクトでこのアプローチを試し、変更に強いアプリケーションを構築してみてください。

関連リンク