T-CREATOR

Electron クリーンアーキテクチャ設計:ドメインと UI を IPC で疎結合に

Electron クリーンアーキテクチャ設計:ドメインと UI を IPC で疎結合に

Electron アプリケーションの開発では、Main プロセスと Renderer プロセスが分離されているため、適切なアーキテクチャ設計がとても重要です。この記事では、IPC(プロセス間通信)を活用してドメインロジックと UI を疎結合に保つクリーンアーキテクチャの実装方法をご紹介します。

コードが増えてくると、どこにどのロジックを書けばいいか迷ってしまうことはありませんか。特に Electron では Main と Renderer という 2 つのプロセスがあるため、責務の分離がより複雑になります。この記事を読めば、保守性の高い Electron アプリケーションを構築できるようになるでしょう。

背景

Electron のプロセスモデル

Electron アプリケーションは、Chromium と Node.js を組み合わせたデスクトップアプリケーションフレームワークです。その特徴的なアーキテクチャとして、Main プロセスRenderer プロセスという 2 つのプロセスが存在します。

以下の図は、Electron の基本的なプロセス構造を示しています。

mermaidflowchart TB
  main["Main プロセス<br/>(Node.js 環境)"]
  renderer1["Renderer プロセス 1<br/>(Chromium)"]
  renderer2["Renderer プロセス 2<br/>(Chromium)"]
  renderer3["Renderer プロセス 3<br/>(Chromium)"]

  main -->|ウィンドウ生成| renderer1
  main -->|ウィンドウ生成| renderer2
  main -->|ウィンドウ生成| renderer3

  renderer1 -.->|IPC 通信| main
  renderer2 -.->|IPC 通信| main
  renderer3 -.->|IPC 通信| main

図のポイント:

  • Main プロセスは 1 つだけ存在し、アプリケーション全体を管理します
  • Renderer プロセスは複数存在可能で、それぞれが独立したウィンドウを表示します
  • 各プロセス間は IPC を通じてのみ通信できます

Main プロセスは Node.js の全機能にアクセスでき、ファイルシステムやネイティブ API を扱えます。一方、Renderer プロセスは Web ブラウザと同じ環境で動作し、HTML・CSS・JavaScript で UI を構築します。

従来の Electron アプリケーションの課題

多くの Electron アプリケーションでは、ビジネスロジックが Main プロセスと Renderer プロセスの両方に散在してしまいがちです。例えば、データの検証ロジックが Renderer 側にあったり、UI の状態管理が Main 側にあったりすると、以下のような問題が発生します。

#課題影響
1ロジックの重複同じ処理を Main と Renderer の両方で実装してしまう
2テストの困難さプロセス間通信を含むテストが複雑になる
3保守性の低下変更時に複数箇所を修正する必要がある
4セキュリティリスクRenderer から直接ファイルシステムにアクセスできてしまう

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

クリーンアーキテクチャは、Robert C. Martin(Uncle Bob)が提唱した設計思想です。この考え方では、アプリケーションを複数の層に分割し、依存関係を一方向に保つことで、変更に強く、テストしやすいシステムを構築できます。

主要な原則は以下の通りです。

  • 依存性逆転の原則: 外側の層は内側の層に依存するが、内側の層は外側の層を知らない
  • 関心事の分離: ビジネスロジック・UI・インフラストラクチャを明確に分ける
  • テスタビリティ: ビジネスロジックは UI やデータベースなしでテストできる

課題

Electron 特有のアーキテクチャ課題

Electron アプリケーションでクリーンアーキテクチャを実現するには、プロセスモデルという特有の制約があります。ここでは、よくある 3 つの課題を見ていきましょう。

ビジネスロジックの配置場所

ビジネスロジックを Main プロセスに置くべきか、Renderer プロセスに置くべきか、という問題があります。Main に置くと、UI の応答性が悪くなる可能性があります。逆に Renderer に置くと、セキュリティ上の懸念が生じますね。

以下の図は、ロジック配置の判断フローを示しています。

mermaidflowchart TD
  start["ビジネスロジック"]
  q1{"ファイルシステム<br/>アクセスが必要?"}
  q2{"ネイティブ API<br/>が必要?"}
  q3{"複数ウィンドウで<br/>共有が必要?"}

  main_result["Main プロセス<br/>に配置"]
  renderer_result["Renderer プロセス<br/>に配置"]
  domain_result["Domain 層として<br/>独立させる"]

  start --> q1
  q1 -->|はい| main_result
  q1 -->|いいえ| q2
  q2 -->|はい| main_result
  q2 -->|いいえ| q3
  q3 -->|はい| main_result
  q3 -->|いいえ| renderer_result

  main_result --> domain_result
  renderer_result --> domain_result

判断のポイント:

  • ファイル操作やネイティブ API が必要なら Main プロセス
  • 純粋な計算やデータ変換は Domain 層として独立
  • UI に密接に関連するロジックのみ Renderer プロセス

IPC 通信の複雑化

プロセス間通信(IPC)は非同期で行われるため、複雑な処理フローでは以下の問題が起こりやすくなります。

#問題点具体例
1コールバック地獄複数の IPC 呼び出しがネストしてしまう
2エラーハンドリングの困難さどのプロセスでエラーが発生したか分かりにくい
3型安全性の欠如IPC のメッセージは文字列ベースで型チェックが効かない
4テストの複雑さIPC をモックするのが難しい

依存関係の管理

Electron アプリケーションでは、以下のような依存関係が複雑に絡み合います。

  • Main プロセスから Renderer プロセスへのメッセージ送信
  • Renderer プロセスから Main プロセスへの関数呼び出し
  • 共通のビジネスロジックへの依存
  • 外部ライブラリへの依存

これらを適切に管理しないと、循環依存や密結合が発生し、変更が困難になってしまいます。

解決策

クリーンアーキテクチャの層構造

Electron アプリケーションにクリーンアーキテクチャを適用する際、以下の 4 つの層に分割します。

以下の図は、各層の依存関係を示しています。矢印は依存の方向を表し、内側の層は外側の層を知りません。

mermaidflowchart TD
  ui["Presentation 層<br/>(UI コンポーネント)"]
  app["Application 層<br/>(ユースケース)"]
  domain["Domain 層<br/>(ビジネスロジック)"]
  infra["Infrastructure 層<br/>(IPC・DB・API)"]

  ui -->|依存| app
  app -->|依存| domain
  infra -->|依存| domain

  ui -.->|IPC 経由で間接的に| infra

各層の責務:

  • Domain 層: ビジネスルールとエンティティ(Main・Renderer から独立)
  • Application 層: ユースケースの実装(Domain 層を組み合わせる)
  • Infrastructure 層: IPC 通信・ファイル操作・外部 API(Main プロセス)
  • Presentation 層: UI コンポーネントとイベントハンドリング(Renderer プロセス)

この構造により、ビジネスロジックはプロセスに依存せず、テスト可能で再利用可能になります。

IPC を抽象化するインターフェース設計

IPC 通信を直接使用するのではなく、抽象的なインターフェースを定義することで、疎結合を実現できます。以下では、Repository パターンを使った設計例をご紹介しましょう。

Domain 層:Repository インターフェース

まず、Domain 層でデータアクセスのインターフェースを定義します。

typescript// src/domain/repositories/UserRepository.ts

/**
 * ユーザーデータへのアクセスを抽象化するリポジトリインターフェース
 * この層では実装の詳細(IPC や DB)を知らない
 */
export interface User {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
}

export interface UserRepository {
  /**
   * ID でユーザーを取得する
   * @throws UserNotFoundError ユーザーが見つからない場合
   */
  findById(id: string): Promise<User | null>;

  /**
   * すべてのユーザーを取得する
   */
  findAll(): Promise<User[]>;

  /**
   * ユーザーを保存する
   * @returns 保存されたユーザー
   */
  save(user: User): Promise<User>;

  /**
   * ユーザーを削除する
   * @throws UserNotFoundError ユーザーが見つからない場合
   */
  delete(id: string): Promise<void>;
}

このインターフェースは IPC や具体的な実装を一切知りません。純粋にビジネスロジックの観点から、どんなデータ操作が必要かを定義しています。

Application 層:ユースケース実装

次に、Application 層でユースケースを実装します。

typescript// src/application/usecases/GetUserUseCase.ts

import {
  UserRepository,
  User,
} from '@/domain/repositories/UserRepository';

/**
 * ユーザー取得ユースケース
 * リポジトリインターフェースに依存し、具体的な実装には依存しない
 */
export class GetUserUseCase {
  constructor(private userRepository: UserRepository) {}

  /**
   * ID でユーザーを取得する
   * ビジネスルール:存在しないユーザーの場合はエラーを投げる
   */
  async execute(userId: string): Promise<User> {
    const user = await this.userRepository.findById(userId);

    if (!user) {
      throw new Error(`User not found: ${userId}`);
    }

    return user;
  }
}
typescript// src/application/usecases/CreateUserUseCase.ts

import {
  UserRepository,
  User,
} from '@/domain/repositories/UserRepository';

/**
 * ユーザー作成ユースケース
 */
export class CreateUserUseCase {
  constructor(private userRepository: UserRepository) {}

  /**
   * 新しいユーザーを作成する
   * ビジネスルール:メールアドレスは必須、名前は 2 文字以上
   */
  async execute(
    name: string,
    email: string
  ): Promise<User> {
    // バリデーション
    if (!email || !email.includes('@')) {
      throw new Error('Invalid email address');
    }

    if (!name || name.length < 2) {
      throw new Error('Name must be at least 2 characters');
    }

    // 新規ユーザーの作成
    const newUser: User = {
      id: this.generateId(),
      name,
      email,
      createdAt: new Date(),
    };

    return await this.userRepository.save(newUser);
  }

  private generateId(): string {
    return `user_${Date.now()}_${Math.random()
      .toString(36)
      .substr(2, 9)}`;
  }
}

ユースケースは Repository インターフェースに依存していますが、その実装(IPC 通信)については何も知りません。

Infrastructure 層:IPC を使った Repository 実装

Main プロセス側で、実際の IPC 通信を実装します。

typescript// src/infrastructure/repositories/IpcUserRepository.ts (Main プロセス)

import { ipcMain } from 'electron';
import {
  UserRepository,
  User,
} from '@/domain/repositories/UserRepository';
import * as fs from 'fs/promises';
import * as path from 'path';

/**
 * IPC を介してユーザーデータにアクセスする Repository 実装
 * Main プロセスで動作し、ファイルシステムに直接アクセスする
 */
export class IpcUserRepository implements UserRepository {
  private dataPath: string;

  constructor(dataPath: string) {
    this.dataPath = dataPath;
    this.setupIpcHandlers();
  }

  /**
   * IPC ハンドラーを設定する
   * Renderer からの要求を受け取り、適切なメソッドを呼び出す
   */
  private setupIpcHandlers(): void {
    ipcMain.handle(
      'user:findById',
      async (_, id: string) => {
        return await this.findById(id);
      }
    );

    ipcMain.handle('user:findAll', async () => {
      return await this.findAll();
    });

    ipcMain.handle('user:save', async (_, user: User) => {
      return await this.save(user);
    });

    ipcMain.handle('user:delete', async (_, id: string) => {
      return await this.delete(id);
    });
  }

  async findById(id: string): Promise<User | null> {
    const users = await this.loadUsers();
    return users.find((u) => u.id === id) || null;
  }

  async findAll(): Promise<User[]> {
    return await this.loadUsers();
  }

  async save(user: User): Promise<User> {
    const users = await this.loadUsers();
    const existingIndex = users.findIndex(
      (u) => u.id === user.id
    );

    if (existingIndex >= 0) {
      users[existingIndex] = user;
    } else {
      users.push(user);
    }

    await this.saveUsers(users);
    return user;
  }

  async delete(id: string): Promise<void> {
    const users = await this.loadUsers();
    const filtered = users.filter((u) => u.id !== id);
    await this.saveUsers(filtered);
  }

  // ファイルからユーザーデータを読み込む
  private async loadUsers(): Promise<User[]> {
    try {
      const data = await fs.readFile(
        this.dataPath,
        'utf-8'
      );
      return JSON.parse(data);
    } catch (error) {
      // ファイルが存在しない場合は空配列を返す
      return [];
    }
  }

  // ユーザーデータをファイルに保存する
  private async saveUsers(users: User[]): Promise<void> {
    await fs.writeFile(
      this.dataPath,
      JSON.stringify(users, null, 2),
      'utf-8'
    );
  }
}

この実装は IPC ハンドラーの設定とファイルシステムへのアクセスを担当しますが、ビジネスロジックは含まれていません。

Presentation 層:Renderer からの IPC 呼び出し

Renderer プロセス側でも、Repository インターフェースに準拠した実装を用意します。

typescript// src/presentation/repositories/RendererUserRepository.ts (Renderer プロセス)

import {
  UserRepository,
  User,
} from '@/domain/repositories/UserRepository';

/**
 * Renderer プロセスから IPC を介して Main プロセスの Repository にアクセスする
 * UserRepository インターフェースを実装し、IPC 通信の詳細を隠蔽する
 */
export class RendererUserRepository
  implements UserRepository
{
  async findById(id: string): Promise<User | null> {
    // window.electron は preload スクリプトで公開された IPC API
    return await window.electron.invoke(
      'user:findById',
      id
    );
  }

  async findAll(): Promise<User[]> {
    return await window.electron.invoke('user:findAll');
  }

  async save(user: User): Promise<User> {
    return await window.electron.invoke('user:save', user);
  }

  async delete(id: string): Promise<void> {
    await window.electron.invoke('user:delete', id);
  }
}
typescript// src/preload/index.ts

import { contextBridge, ipcRenderer } from 'electron';

/**
 * Preload スクリプト:安全に IPC を公開する
 * contextBridge を使用して、最小限の API のみを公開
 */
contextBridge.exposeInMainWorld('electron', {
  invoke: (channel: string, ...args: any[]) => {
    // 許可されたチャンネルのみ通信可能にする
    const validChannels = [
      'user:findById',
      'user:findAll',
      'user:save',
      'user:delete',
    ];

    if (validChannels.includes(channel)) {
      return ipcRenderer.invoke(channel, ...args);
    }

    throw new Error(`Invalid IPC channel: ${channel}`);
  },
});

Preload スクリプトを使うことで、Renderer プロセスから Main プロセスへの通信を安全に制御できます。

型安全な IPC 通信の実現

IPC 通信は本来、文字列ベースで型安全性がありません。しかし、TypeScript の型定義を活用することで、コンパイル時に型チェックを行えます。

IPC チャンネル定義の型化

typescript// src/shared/ipc/channels.ts

/**
 * IPC チャンネルの型定義
 * すべての IPC 通信はこの型を通じて行われる
 */
export interface IpcChannels {
  // ユーザー関連
  'user:findById': {
    request: { id: string };
    response: User | null;
  };
  'user:findAll': {
    request: void;
    response: User[];
  };
  'user:save': {
    request: { user: User };
    response: User;
  };
  'user:delete': {
    request: { id: string };
    response: void;
  };
}

/**
 * チャンネル名の型
 */
export type IpcChannelName = keyof IpcChannels;

/**
 * 特定のチャンネルのリクエスト型を取得
 */
export type IpcRequest<T extends IpcChannelName> =
  IpcChannels[T]['request'];

/**
 * 特定のチャンネルのレスポンス型を取得
 */
export type IpcResponse<T extends IpcChannelName> =
  IpcChannels[T]['response'];

この型定義により、チャンネル名の typo や、引数・戻り値の型の不一致をコンパイル時に検出できます。

型安全な IPC ラッパー

typescript// src/shared/ipc/TypedIpc.ts

import {
  ipcMain,
  ipcRenderer,
  IpcMainInvokeEvent,
} from 'electron';
import {
  IpcChannelName,
  IpcRequest,
  IpcResponse,
} from './channels';

/**
 * 型安全な IPC Main ハンドラー登録
 */
export class TypedIpcMain {
  static handle<T extends IpcChannelName>(
    channel: T,
    handler: (
      event: IpcMainInvokeEvent,
      request: IpcRequest<T>
    ) => Promise<IpcResponse<T>> | IpcResponse<T>
  ): void {
    ipcMain.handle(channel, async (event, request) => {
      return await handler(event, request);
    });
  }
}

/**
 * 型安全な IPC Renderer 呼び出し
 */
export class TypedIpcRenderer {
  static invoke<T extends IpcChannelName>(
    channel: T,
    request: IpcRequest<T>
  ): Promise<IpcResponse<T>> {
    return ipcRenderer.invoke(channel, request);
  }
}

これらのラッパークラスを使用することで、型安全な IPC 通信が可能になります。

typescript// Main プロセスでの使用例

TypedIpcMain.handle(
  'user:findById',
  async (event, request) => {
    // request は自動的に { id: string } 型と推論される
    const { id } = request;
    const user = await userRepository.findById(id);
    // 戻り値は User | null 型でなければコンパイルエラー
    return user;
  }
);
typescript// Renderer プロセスでの使用例

// リクエストとレスポンスの型が自動的に推論される
const user = await TypedIpcRenderer.invoke(
  'user:findById',
  { id: 'user_123' }
);
// user は User | null 型

依存性の注入(DI)パターン

クリーンアーキテクチャを実現するには、依存性の注入が不可欠です。これにより、テスト時にモックを差し替えたり、実装を切り替えたりできるようになります。

DI コンテナの実装

typescript// src/shared/di/Container.ts

/**
 * シンプルな DI コンテナ
 * クラスインスタンスを登録・解決する
 */
export class Container {
  private services = new Map<string, any>();

  /**
   * サービスを登録する
   * @param key サービスの識別子
   * @param instance サービスのインスタンス
   */
  register<T>(key: string, instance: T): void {
    this.services.set(key, instance);
  }

  /**
   * サービスを解決する
   * @param key サービスの識別子
   * @throws Error サービスが見つからない場合
   */
  resolve<T>(key: string): T {
    const service = this.services.get(key);

    if (!service) {
      throw new Error(`Service not found: ${key}`);
    }

    return service as T;
  }

  /**
   * サービスが登録されているか確認する
   */
  has(key: string): boolean {
    return this.services.has(key);
  }

  /**
   * すべてのサービスをクリアする(主にテスト用)
   */
  clear(): void {
    this.services.clear();
  }
}

// グローバルコンテナのインスタンス
export const container = new Container();
typescript// src/shared/di/ServiceKeys.ts

/**
 * DI コンテナで使用するサービスキーの定義
 * 文字列の typo を防ぐために定数化
 */
export const ServiceKeys = {
  // Repositories
  USER_REPOSITORY: 'UserRepository',

  // Use Cases
  GET_USER_USE_CASE: 'GetUserUseCase',
  CREATE_USER_USE_CASE: 'CreateUserUseCase',

  // Infrastructure
  DATA_PATH: 'DataPath',
} as const;

Main プロセスでの DI 設定

typescript// src/main/di/setupContainer.ts

import { container } from '@/shared/di/Container';
import { ServiceKeys } from '@/shared/di/ServiceKeys';
import { IpcUserRepository } from '@/infrastructure/repositories/IpcUserRepository';
import { GetUserUseCase } from '@/application/usecases/GetUserUseCase';
import { CreateUserUseCase } from '@/application/usecases/CreateUserUseCase';
import { app } from 'electron';
import * as path from 'path';

/**
 * Main プロセス用の DI コンテナをセットアップする
 */
export function setupContainer(): void {
  // データパスの登録
  const dataPath = path.join(
    app.getPath('userData'),
    'users.json'
  );
  container.register(ServiceKeys.DATA_PATH, dataPath);

  // Repository の登録
  const userRepository = new IpcUserRepository(dataPath);
  container.register(
    ServiceKeys.USER_REPOSITORY,
    userRepository
  );

  // Use Cases の登録
  const getUserUseCase = new GetUserUseCase(userRepository);
  container.register(
    ServiceKeys.GET_USER_USE_CASE,
    getUserUseCase
  );

  const createUserUseCase = new CreateUserUseCase(
    userRepository
  );
  container.register(
    ServiceKeys.CREATE_USER_USE_CASE,
    createUserUseCase
  );
}
typescript// src/main/index.ts

import { app, BrowserWindow } from 'electron';
import { setupContainer } from './di/setupContainer';

/**
 * Main プロセスのエントリーポイント
 */
app.on('ready', () => {
  // DI コンテナの初期化(最初に実行)
  setupContainer();

  // メインウィンドウの作成
  const mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false,
    },
  });

  mainWindow.loadFile('index.html');
});

Renderer プロセスでの DI 設定

typescript// src/presentation/di/setupContainer.ts

import { container } from '@/shared/di/Container';
import { ServiceKeys } from '@/shared/di/ServiceKeys';
import { RendererUserRepository } from '@/presentation/repositories/RendererUserRepository';
import { GetUserUseCase } from '@/application/usecases/GetUserUseCase';
import { CreateUserUseCase } from '@/application/usecases/CreateUserUseCase';

/**
 * Renderer プロセス用の DI コンテナをセットアップする
 */
export function setupContainer(): void {
  // Repository の登録(IPC 経由で Main と通信)
  const userRepository = new RendererUserRepository();
  container.register(
    ServiceKeys.USER_REPOSITORY,
    userRepository
  );

  // Use Cases の登録
  const getUserUseCase = new GetUserUseCase(userRepository);
  container.register(
    ServiceKeys.GET_USER_USE_CASE,
    getUserUseCase
  );

  const createUserUseCase = new CreateUserUseCase(
    userRepository
  );
  container.register(
    ServiceKeys.CREATE_USER_USE_CASE,
    createUserUseCase
  );
}

具体例

実践:ユーザー管理機能の実装

ここでは、クリーンアーキテクチャを適用した具体的なユーザー管理機能を実装してみましょう。React を使った UI コンポーネントから、IPC を経由して Main プロセスのビジネスロジックを呼び出す流れを見ていきます。

以下の図は、ユーザー作成処理の全体的なフローを示しています。

mermaidsequenceDiagram
  participant UI as React Component<br/>(Renderer)
  participant Hook as useUserService<br/>(Renderer)
  participant RepoR as RendererUserRepository<br/>(Renderer)
  participant IPC as IPC 通信
  participant RepoM as IpcUserRepository<br/>(Main)
  participant UseCase as CreateUserUseCase<br/>(Main)
  participant FS as ファイルシステム

  UI->>Hook: createUser() 呼び出し
  Hook->>RepoR: save(user) 呼び出し
  RepoR->>IPC: invoke('user:save')
  IPC->>RepoM: IPC ハンドラー起動
  RepoM->>UseCase: execute() 呼び出し
  UseCase->>UseCase: バリデーション実行
  UseCase->>RepoM: save(user) 呼び出し
  RepoM->>FS: ファイルに書き込み
  FS-->>RepoM: 完了
  RepoM-->>UseCase: 保存済みユーザー
  UseCase-->>RepoM: 保存済みユーザー
  RepoM-->>IPC: レスポンス返却
  IPC-->>RepoR: レスポンス返却
  RepoR-->>Hook: ユーザーデータ返却
  Hook-->>UI: 状態更新・再レンダリング

シーケンスのポイント:

  • Renderer プロセスの UI から Main プロセスのユースケースまで、明確に層が分かれています
  • 各層は次の層のインターフェースのみに依存し、実装の詳細を知りません
  • IPC 通信は Repository 層で隠蔽され、上位層には影響しません

React コンポーネントの実装

まず、UI コンポーネントを実装します。

typescript// src/presentation/components/UserList.tsx

import React, { useEffect, useState } from 'react';
import { User } from '@/domain/repositories/UserRepository';
import { useUserService } from '../hooks/useUserService';

/**
 * ユーザー一覧コンポーネント
 */
export const UserList: React.FC = () => {
  const { users, loading, error, fetchUsers, deleteUser } =
    useUserService();

  // 初回マウント時にユーザー一覧を取得
  useEffect(() => {
    fetchUsers();
  }, []);

  // ローディング中の表示
  if (loading) {
    return <div>読み込み中...</div>;
  }

  // エラー発生時の表示
  if (error) {
    return (
      <div style={{ color: 'red' }}>
        エラー: {error.message}
      </div>
    );
  }

  return (
    <div>
      <h2>ユーザー一覧</h2>
      {users.length === 0 ? (
        <p>ユーザーが登録されていません</p>
      ) : (
        <table>
          <thead>
            <tr>
              <th>ID</th>
              <th>名前</th>
              <th>メールアドレス</th>
              <th>登録日</th>
              <th>操作</th>
            </tr>
          </thead>
          <tbody>
            {users.map((user) => (
              <tr key={user.id}>
                <td>{user.id}</td>
                <td>{user.name}</td>
                <td>{user.email}</td>
                <td>
                  {new Date(
                    user.createdAt
                  ).toLocaleDateString()}
                </td>
                <td>
                  <button
                    onClick={() => deleteUser(user.id)}
                  >
                    削除
                  </button>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
    </div>
  );
};
typescript// src/presentation/components/UserForm.tsx

import React, { useState } from 'react';
import { useUserService } from '../hooks/useUserService';

/**
 * ユーザー作成フォームコンポーネント
 */
export const UserForm: React.FC = () => {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const { createUser, loading, error } = useUserService();

  // フォーム送信ハンドラー
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    try {
      // ユーザー作成処理を呼び出す
      await createUser(name, email);

      // 成功したらフォームをクリア
      setName('');
      setEmail('');

      alert('ユーザーを作成しました');
    } catch (err) {
      // エラーは useUserService 内で処理される
      console.error('User creation failed:', err);
    }
  };

  return (
    <div>
      <h2>新規ユーザー登録</h2>
      <form onSubmit={handleSubmit}>
        <div>
          <label>
            名前:
            <input
              type='text'
              value={name}
              onChange={(e) => setName(e.target.value)}
              required
              minLength={2}
            />
          </label>
        </div>
        <div>
          <label>
            メールアドレス:
            <input
              type='email'
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              required
            />
          </label>
        </div>
        <button type='submit' disabled={loading}>
          {loading ? '作成中...' : '作成'}
        </button>
        {error && (
          <div style={{ color: 'red' }}>
            エラー: {error.message}
          </div>
        )}
      </form>
    </div>
  );
};

UI コンポーネントは、カスタムフック useUserService を通じてビジネスロジックにアクセスします。IPC や Repository については一切知りません。

カスタムフックの実装

typescript// src/presentation/hooks/useUserService.ts

import { useState, useCallback } from 'react';
import { User } from '@/domain/repositories/UserRepository';
import { container } from '@/shared/di/Container';
import { ServiceKeys } from '@/shared/di/ServiceKeys';
import { GetUserUseCase } from '@/application/usecases/GetUserUseCase';
import { CreateUserUseCase } from '@/application/usecases/CreateUserUseCase';
import { UserRepository } from '@/domain/repositories/UserRepository';

/**
 * ユーザー管理のためのカスタムフック
 * UI コンポーネントからビジネスロジックを分離する
 */
export function useUserService() {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  // DI コンテナからサービスを取得
  const userRepository = container.resolve<UserRepository>(
    ServiceKeys.USER_REPOSITORY
  );
  const createUserUseCase =
    container.resolve<CreateUserUseCase>(
      ServiceKeys.CREATE_USER_USE_CASE
    );

  /**
   * すべてのユーザーを取得する
   */
  const fetchUsers = useCallback(async () => {
    setLoading(true);
    setError(null);

    try {
      const result = await userRepository.findAll();
      setUsers(result);
    } catch (err) {
      setError(err as Error);
    } finally {
      setLoading(false);
    }
  }, [userRepository]);

  /**
   * 新しいユーザーを作成する
   */
  const createUser = useCallback(
    async (name: string, email: string) => {
      setLoading(true);
      setError(null);

      try {
        await createUserUseCase.execute(name, email);
        // 作成後、一覧を再取得
        await fetchUsers();
      } catch (err) {
        setError(err as Error);
        throw err;
      } finally {
        setLoading(false);
      }
    },
    [createUserUseCase, fetchUsers]
  );

  /**
   * ユーザーを削除する
   */
  const deleteUser = useCallback(
    async (id: string) => {
      setLoading(true);
      setError(null);

      try {
        await userRepository.delete(id);
        // 削除後、一覧を再取得
        await fetchUsers();
      } catch (err) {
        setError(err as Error);
      } finally {
        setLoading(false);
      }
    },
    [userRepository, fetchUsers]
  );

  return {
    users,
    loading,
    error,
    fetchUsers,
    createUser,
    deleteUser,
  };
}

このカスタムフックは、状態管理とビジネスロジックの呼び出しを担当します。UI コンポーネントは、このフックを使うだけで複雑な処理を実行できますね。

テストの実装

クリーンアーキテクチャの大きなメリットは、テストのしやすさです。各層を独立してテストできます。

Domain 層のテスト

typescript// tests/application/usecases/CreateUserUseCase.test.ts

import { CreateUserUseCase } from '@/application/usecases/CreateUserUseCase';
import {
  UserRepository,
  User,
} from '@/domain/repositories/UserRepository';

/**
 * モックの Repository 実装
 * 実際の IPC 通信やファイルアクセスなしでテストできる
 */
class MockUserRepository implements UserRepository {
  private users: User[] = [];

  async findById(id: string): Promise<User | null> {
    return this.users.find((u) => u.id === id) || null;
  }

  async findAll(): Promise<User[]> {
    return [...this.users];
  }

  async save(user: User): Promise<User> {
    this.users.push(user);
    return user;
  }

  async delete(id: string): Promise<void> {
    this.users = this.users.filter((u) => u.id !== id);
  }
}

describe('CreateUserUseCase', () => {
  let useCase: CreateUserUseCase;
  let repository: MockUserRepository;

  beforeEach(() => {
    repository = new MockUserRepository();
    useCase = new CreateUserUseCase(repository);
  });

  test('正常にユーザーを作成できる', async () => {
    const user = await useCase.execute(
      '太郎',
      'taro@example.com'
    );

    expect(user.name).toBe('太郎');
    expect(user.email).toBe('taro@example.com');
    expect(user.id).toBeDefined();
    expect(user.createdAt).toBeInstanceOf(Date);
  });

  test('メールアドレスが無効な場合はエラーを投げる', async () => {
    await expect(
      useCase.execute('太郎', 'invalid-email')
    ).rejects.toThrow('Invalid email address');
  });

  test('名前が短すぎる場合はエラーを投げる', async () => {
    await expect(
      useCase.execute('A', 'taro@example.com')
    ).rejects.toThrow('Name must be at least 2 characters');
  });

  test('作成したユーザーが Repository に保存される', async () => {
    await useCase.execute('太郎', 'taro@example.com');

    const users = await repository.findAll();
    expect(users).toHaveLength(1);
    expect(users[0].name).toBe('太郎');
  });
});

このテストでは、モックの Repository を使用しているため、IPC 通信やファイルシステムに依存せずにビジネスロジックをテストできます。

Integration テスト

typescript// tests/integration/UserManagement.test.ts

import { app } from 'electron';
import { IpcUserRepository } from '@/infrastructure/repositories/IpcUserRepository';
import { CreateUserUseCase } from '@/application/usecases/CreateUserUseCase';
import { GetUserUseCase } from '@/application/usecases/GetUserUseCase';
import * as path from 'path';
import * as fs from 'fs/promises';

/**
 * 統合テスト:実際の IPC と ファイルシステムを使用
 */
describe('User Management Integration', () => {
  let repository: IpcUserRepository;
  let createUseCase: CreateUserUseCase;
  let getUserUseCase: GetUserUseCase;
  let testDataPath: string;

  beforeAll(async () => {
    // テスト用の一時ファイルパスを作成
    testDataPath = path.join(__dirname, 'test-users.json');

    // Repository とユースケースをセットアップ
    repository = new IpcUserRepository(testDataPath);
    createUseCase = new CreateUserUseCase(repository);
    getUserUseCase = new GetUserUseCase(repository);
  });

  afterEach(async () => {
    // テスト後にファイルを削除
    try {
      await fs.unlink(testDataPath);
    } catch (err) {
      // ファイルが存在しない場合は無視
    }
  });

  test('ユーザーの作成と取得ができる', async () => {
    // ユーザーを作成
    const createdUser = await createUseCase.execute(
      '太郎',
      'taro@example.com'
    );

    // 作成したユーザーを取得
    const fetchedUser = await getUserUseCase.execute(
      createdUser.id
    );

    expect(fetchedUser).toEqual(createdUser);
    expect(fetchedUser.name).toBe('太郎');
    expect(fetchedUser.email).toBe('taro@example.com');
  });

  test('複数ユーザーを作成し、すべて取得できる', async () => {
    // 複数のユーザーを作成
    await createUseCase.execute('太郎', 'taro@example.com');
    await createUseCase.execute(
      '花子',
      'hanako@example.com'
    );
    await createUseCase.execute('次郎', 'jiro@example.com');

    // すべてのユーザーを取得
    const users = await repository.findAll();

    expect(users).toHaveLength(3);
    expect(users.map((u) => u.name)).toEqual([
      '太郎',
      '花子',
      '次郎',
    ]);
  });

  test('ファイルにデータが永続化される', async () => {
    // ユーザーを作成
    await createUseCase.execute('太郎', 'taro@example.com');

    // ファイルが作成されていることを確認
    const fileContent = await fs.readFile(
      testDataPath,
      'utf-8'
    );
    const data = JSON.parse(fileContent);

    expect(Array.isArray(data)).toBe(true);
    expect(data).toHaveLength(1);
    expect(data[0].name).toBe('太郎');
  });
});

統合テストでは、実際の Repository 実装を使用し、ファイルシステムへの読み書きまで含めてテストします。

エラーハンドリング戦略

クリーンアーキテクチャでは、各層でエラーを適切に処理し、上位層に伝播させます。

カスタムエラークラスの定義

typescript// src/domain/errors/DomainError.ts

/**
 * ドメイン層のベースエラークラス
 */
export abstract class DomainError extends Error {
  constructor(message: string) {
    super(message);
    this.name = this.constructor.name;

    // スタックトレースを正しく設定
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, this.constructor);
    }
  }
}

/**
 * ユーザーが見つからない場合のエラー
 * エラーコード: USER_NOT_FOUND
 */
export class UserNotFoundError extends DomainError {
  readonly code = 'USER_NOT_FOUND';

  constructor(userId: string) {
    super(`User not found: ${userId}`);
  }
}

/**
 * バリデーションエラー
 * エラーコード: VALIDATION_ERROR
 */
export class ValidationError extends DomainError {
  readonly code = 'VALIDATION_ERROR';

  constructor(field: string, message: string) {
    super(`Validation failed for ${field}: ${message}`);
  }
}
typescript// src/infrastructure/errors/InfrastructureError.ts

/**
 * インフラストラクチャ層のベースエラークラス
 */
export abstract class InfrastructureError extends Error {
  constructor(message: string) {
    super(message);
    this.name = this.constructor.name;
  }
}

/**
 * IPC 通信エラー
 * エラーコード: IPC_ERROR
 */
export class IpcError extends InfrastructureError {
  readonly code = 'IPC_ERROR';

  constructor(channel: string, originalError: Error) {
    super(
      `IPC error on channel "${channel}": ${originalError.message}`
    );
  }
}

/**
 * ファイルシステムエラー
 * エラーコード: FILE_SYSTEM_ERROR
 */
export class FileSystemError extends InfrastructureError {
  readonly code = 'FILE_SYSTEM_ERROR';

  constructor(
    operation: string,
    path: string,
    originalError: Error
  ) {
    super(
      `File system error during ${operation} on "${path}": ${originalError.message}`
    );
  }
}

エラーハンドリングの実装

typescript// src/infrastructure/repositories/IpcUserRepository.ts(エラー処理追加版)

import { FileSystemError } from '../errors/InfrastructureError';

export class IpcUserRepository implements UserRepository {
  // ... 既存のコード ...

  private async loadUsers(): Promise<User[]> {
    try {
      const data = await fs.readFile(
        this.dataPath,
        'utf-8'
      );
      return JSON.parse(data);
    } catch (error) {
      if (
        (error as NodeJS.ErrnoException).code === 'ENOENT'
      ) {
        // ファイルが存在しない場合は空配列を返す
        return [];
      }

      // その他のエラーはラップして投げる
      throw new FileSystemError(
        'read',
        this.dataPath,
        error as Error
      );
    }
  }

  private async saveUsers(users: User[]): Promise<void> {
    try {
      await fs.writeFile(
        this.dataPath,
        JSON.stringify(users, null, 2),
        'utf-8'
      );
    } catch (error) {
      throw new FileSystemError(
        'write',
        this.dataPath,
        error as Error
      );
    }
  }
}
typescript// src/presentation/hooks/useUserService.ts(エラー処理追加版)

export function useUserService() {
  // ... 既存のコード ...

  const createUser = useCallback(
    async (name: string, email: string) => {
      setLoading(true);
      setError(null);

      try {
        await createUserUseCase.execute(name, email);
        await fetchUsers();
      } catch (err) {
        // エラーの種類に応じて適切なメッセージを設定
        const error = err as any;

        if (error.code === 'VALIDATION_ERROR') {
          setError(
            new Error(`入力エラー: ${error.message}`)
          );
        } else if (error.code === 'FILE_SYSTEM_ERROR') {
          setError(
            new Error(
              'データの保存に失敗しました。もう一度お試しください。'
            )
          );
        } else if (error.code === 'IPC_ERROR') {
          setError(
            new Error(
              '通信エラーが発生しました。アプリケーションを再起動してください。'
            )
          );
        } else {
          setError(
            new Error('予期しないエラーが発生しました。')
          );
        }

        // エラーログを記録(本番環境では適切なログサービスに送信)
        console.error('User creation error:', error);

        throw err;
      } finally {
        setLoading(false);
      }
    },
    [createUserUseCase, fetchUsers]
  );

  return {
    users,
    loading,
    error,
    fetchUsers,
    createUser,
    deleteUser,
  };
}

エラーをカスタムクラスで定義することで、エラーコードで検索しやすくなり、トラブルシューティングが容易になります。

まとめ

この記事では、Electron アプリケーションにクリーンアーキテクチャを適用し、IPC を通じてドメインロジックと UI を疎結合に保つ方法をご紹介しました。

重要なポイントの振り返り

#ポイント効果
1層の分離(Domain・Application・Infrastructure・Presentation)責務が明確になり、変更の影響範囲が限定される
2Repository パターンによる IPC の抽象化プロセス間通信の詳細がビジネスロジックから隠蔽される
3型安全な IPC 通信コンパイル時に型エラーを検出し、バグを未然に防ぐ
4依存性の注入(DI)テストしやすく、実装の差し替えが容易になる
5カスタムエラークラスエラーの種類が明確になり、検索性が向上する

クリーンアーキテクチャのメリット

このアーキテクチャを採用することで、以下のメリットが得られます。

保守性の向上: ビジネスロジックが UI やインフラストラクチャから独立しているため、変更の影響範囲が限定されます。例えば、データの保存先をファイルから SQLite に変更する場合、Repository の実装を差し替えるだけで済みますね。

テスタビリティ: 各層を独立してテストできるため、ユニットテストが書きやすくなります。モックを使えば、IPC 通信やファイルシステムに依存せずにビジネスロジックをテストできます。

再利用性: Domain 層と Application 層は Main プロセスと Renderer プロセスの両方で使用できます。同じビジネスロジックを複数のウィンドウで共有することも簡単です。

セキュリティ: Renderer プロセスから直接ファイルシステムやネイティブ API にアクセスできないため、セキュリティリスクが低減されます。すべての重要な操作は Main プロセス経由で行われます。

さらなる改善の可能性

今回ご紹介した実装は基本的なものですが、さらに以下のような改善も可能です。

  • 状態管理の導入: Redux や MobX などを使って、アプリケーション全体の状態を一元管理する
  • キャッシュ機構: 頻繁にアクセスされるデータをメモリにキャッシュし、パフォーマンスを向上させる
  • ログ基盤: 構造化ログを導入し、本番環境でのトラブルシューティングを容易にする
  • 自動テスト: CI/CD パイプラインに統合し、コミットごとに自動でテストを実行する

クリーンアーキテクチャは最初の学習コストこそありますが、一度習得すれば、どんな規模の Electron アプリケーションでも適用できる強力な設計パターンです。ぜひ、あなたのプロジェクトでも試してみてください。

関連リンク