T-CREATOR

Jotai ドメイン駆動設計:ユースケースと atom の境界を引く実践

Jotai ドメイン駆動設計:ユースケースと atom の境界を引く実践

Jotai はシンプルで柔軟な状態管理ライブラリですが、アプリケーションが成長するにつれて「どこまでを atom で管理し、どこからをユースケース層に切り出すべきか」という設計の悩みが生まれます。特にドメイン駆動設計(DDD)の考え方を取り入れる場合、atom とユースケースの境界を適切に引くことが、保守性の高いコードを書く鍵となるでしょう。

本記事では、Jotai を用いた状態管理において、ドメインロジックとユースケースをどのように分離し、atom の責務をどう定義すればよいのかを具体例とともに解説します。

背景

Jotai は React アプリケーションにおいて、atom という単位で状態を管理するライブラリです。atom はシンプルな状態のカプセル化を提供し、他の atom と組み合わせることで派生状態を作り出せます。

しかし、ビジネスロジックが複雑になると、以下のような課題が浮上します。

  • atom にビジネスロジックを詰め込みすぎる: 状態更新のロジックが atom 内に散在し、テストや再利用が困難になります
  • ユースケースとの境界が曖昧: どこまでを atom で表現し、どこからを関数(ユースケース)として切り出すべきか判断が難しくなります
  • ドメイン知識の散逸: ドメインに関する知識が UI コンポーネントや atom に分散し、ドメインモデルが見えにくくなります

ドメイン駆動設計では、ドメインロジックをドメイン層に集約し、ユースケース層がドメインを orchestrate(調整)する役割を担います。Jotai においても同様の設計思想を適用することで、責務の分離と保守性の向上が期待できるでしょう。

以下の図は、Jotai におけるレイヤー構成の全体像を示しています。

mermaidflowchart TB
  ui["UI コンポーネント"]
  usecase["ユースケース層"]
  atom_layer["Atom 層<br/>(状態管理)"]
  domain["ドメイン層<br/>(ビジネスロジック)"]

  ui -->|hooks で購読| atom_layer
  ui -->|呼び出し| usecase
  usecase -->|状態更新| atom_layer
  usecase -->|利用| domain
  atom_layer -.->|参照のみ| domain

この図から、UI コンポーネントは atom を購読し、ユースケースを呼び出すことで状態を更新します。ユースケース層はドメイン層のロジックを利用しつつ、atom の状態を更新する役割を担います。

課題

Jotai を用いたアプリケーション開発において、以下のような課題が発生しがちです。

atom にロジックが集中する

atom は状態の保持と派生状態の計算を担いますが、ビジネスロジックまで atom 内に記述すると、以下の問題が生じます。

  • テストが困難: atom の内部ロジックをテストするには、Jotai のテストユーティリティを使う必要があり、純粋関数のテストと比べて煩雑になります
  • 再利用性の低下: ビジネスロジックが atom に閉じ込められるため、他のコンテキストでの再利用が難しくなります
  • 依存関係の複雑化: atom 間の依存が増えると、依存グラフが複雑になり、デバッグが困難になります

ユースケースとの境界が不明瞭

「どこまでを atom で表現し、どこからをユースケースとして切り出すべきか」という判断基準が曖昧だと、設計の一貫性が失われます。

  • 状態更新ロジックの重複: 同じビジネスロジックが複数の atom や UI コンポーネントに散在し、変更時の修正箇所が増えます
  • ドメイン知識の分散: ドメインに関する知識が UI コンポーネント、atom、ユースケースに分散し、ドメインモデルが見えにくくなります

以下の図は、境界が不明瞭な場合の問題を示しています。

mermaidflowchart LR
  subgraph problem["境界が不明瞭な状態"]
    ui1["コンポーネント A"]
    ui2["コンポーネント B"]
    atom1["atom A"]
    atom2["atom B"]
    logic1["ビジネスロジック"]

    ui1 -->|直接ロジック呼び出し| logic1
    ui2 -->|直接ロジック呼び出し| logic1
    atom1 -.->|ロジック埋め込み| logic1
    atom2 -.->|ロジック埋め込み| logic1
  end

  problem -->|結果| issues["・重複コード<br/>・テスト困難<br/>・変更影響の拡大"]

この図から、ビジネスロジックが UI コンポーネントと atom の双方に埋め込まれ、重複や保守性の低下を招いていることがわかります。

ドメインロジックの散逸

ドメイン駆動設計では、ドメインロジックをドメイン層に集約し、ユースケース層がドメインを調整する役割を担います。しかし、Jotai を使うと、ドメインロジックが atom や UI コンポーネントに散在しやすくなります。

  • ドメインモデルの不在: ドメインに関する知識が状態管理やコンポーネントに埋め込まれ、ドメインモデルとしての明確な表現がなくなります
  • ビジネスルールの変更が困難: ドメインロジックが複数箇所に分散しているため、ビジネスルールの変更時に修正箇所が増え、バグの温床になります

解決策

Jotai とドメイン駆動設計を組み合わせる際の設計原則は、以下の通りです。

atom は状態のコンテナに徹する

atom はあくまで状態の保持と派生状態の計算に専念し、ビジネスロジックは含めません。atom の責務は以下に限定されます。

  • プリミティブな状態の保持: ユーザー情報、フォーム入力値、API レスポンスなど
  • 派生状態の計算: 他の atom から計算される状態(例:フィルタリング済みリスト、合計値)
  • 状態の購読と更新: UI コンポーネントが atom を購読し、ユースケースが atom を更新する
typescriptimport { atom } from 'jotai';

// ユーザー情報を保持する atom
export const userAtom = atom<User | null>(null);

// フィルタリング条件を保持する atom
export const filterAtom = atom<string>('');

このように、atom は状態の保持に専念し、ビジネスロジックは持ちません。

ドメイン層にビジネスロジックを集約する

ドメイン層は、ビジネスルールやドメイン知識をカプセル化します。atom やユースケースから独立した純粋関数として実装することで、テストが容易になります。

ドメイン層の責務は以下の通りです。

  • ビジネスルールの実装: バリデーション、計算、変換など
  • ドメインモデルの定義: エンティティ、値オブジェクト、ドメインサービス
  • 不変性の保証: ドメインオブジェクトの状態を不変に保つ
typescript// ドメイン層: ユーザーのバリデーション
export class User {
  constructor(
    public readonly id: string,
    public readonly name: string,
    public readonly email: string
  ) {}

  // ビジネスルール: 名前は 2 文字以上
  static validateName(name: string): boolean {
    return name.length >= 2;
  }

  // ビジネスルール: メールアドレスの形式チェック
  static validateEmail(email: string): boolean {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  }
}

ドメイン層は atom や React に依存せず、純粋な TypeScript コードとして実装します。これにより、ドメインロジックのテストが容易になり、再利用性も向上するでしょう。

ユースケース層でドメインと atom を調整する

ユースケース層は、ドメインロジックを呼び出し、atom の状態を更新する役割を担います。ユースケースは以下の責務を持ちます。

  • ドメインロジックの呼び出し: ドメイン層の関数やメソッドを利用
  • atom の状態更新: ビジネスロジックの結果を atom に反映
  • 外部サービスとの連携: API 呼び出し、ローカルストレージへのアクセスなど
typescriptimport { useSetAtom } from 'jotai';
import { userAtom } from './atoms';
import { User } from './domain/User';

// ユースケース: ユーザー情報を更新する
export const useUpdateUser = () => {
  const setUser = useSetAtom(userAtom);

  return (id: string, name: string, email: string) => {
    // ドメイン層のバリデーションを利用
    if (!User.validateName(name)) {
      throw new Error(
        '名前は 2 文字以上である必要があります'
      );
    }
    if (!User.validateEmail(email)) {
      throw new Error(
        '有効なメールアドレスを入力してください'
      );
    }

    // ドメインオブジェクトを生成
    const user = new User(id, name, email);

    // atom の状態を更新
    setUser(user);
  };
};

ユースケース層は、ドメインロジックと atom の橋渡しをします。UI コンポーネントはユースケースを呼び出すだけで、ビジネスロジックの詳細を知る必要がありません。

以下の図は、ドメイン層、ユースケース層、atom 層の関係を示しています。

mermaidflowchart TB
  ui["UI コンポーネント"]
  usecase["ユースケース<br/>(useUpdateUser)"]
  domain["ドメイン層<br/>(User クラス)"]
  atom_layer["Atom 層<br/>(userAtom)"]

  ui -->|呼び出し| usecase
  usecase -->|バリデーション| domain
  usecase -->|状態更新| atom_layer
  ui -->|購読| atom_layer

この図から、UI コンポーネントはユースケースを呼び出し、ユースケースがドメイン層のバリデーションを利用して atom の状態を更新することがわかります。

レイヤー分離の原則

以下の表は、各レイヤーの責務を整理したものです。

#レイヤー責務依存先
1UI コンポーネント表示とユーザー操作の受付ユースケース、atomUserForm.tsx
2ユースケース層ドメインロジックの呼び出しと atom の更新ドメイン層、atomuseUpdateUser.ts
3Atom 層状態の保持と派生状態の計算ドメイン層(参照のみ)userAtom.ts
4ドメイン層ビジネスルールとドメインモデルなしUser.ts

この表から、依存の方向は常に「上位レイヤー → 下位レイヤー」であり、ドメイン層は他のレイヤーに依存しないことがわかります。

具体例

ここでは、ユーザー管理機能を例に、Jotai とドメイン駆動設計を組み合わせた実装を示します。

ドメイン層の実装

まず、ドメイン層でユーザーエンティティとビジネスルールを定義します。

typescript// domain/User.ts

export class User {
  constructor(
    public readonly id: string,
    public readonly name: string,
    public readonly email: string
  ) {}

  // 名前のバリデーション
  static validateName(name: string): boolean {
    return name.length >= 2;
  }

  // メールアドレスのバリデーション
  static validateEmail(email: string): boolean {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  }

  // ユーザー作成のファクトリメソッド
  static create(
    id: string,
    name: string,
    email: string
  ): User {
    if (!this.validateName(name)) {
      throw new Error(
        '名前は 2 文字以上である必要があります'
      );
    }
    if (!this.validateEmail(email)) {
      throw new Error(
        '有効なメールアドレスを入力してください'
      );
    }
    return new User(id, name, email);
  }
}

ドメイン層は純粋な TypeScript コードであり、Jotai や React に依存しません。これにより、ドメインロジックのテストが容易になります。

Atom 層の実装

次に、atom 層でユーザー情報を保持する atom を定義します。

typescript// atoms/userAtom.ts

import { atom } from 'jotai';
import { User } from '../domain/User';

// ユーザー情報を保持する atom
export const userAtom = atom<User | null>(null);

// ユーザーリストを保持する atom
export const userListAtom = atom<User[]>([]);

atom は状態の保持に専念し、ビジネスロジックは含みません。

ユースケース層の実装

ユースケース層では、ドメインロジックを呼び出し、atom の状態を更新する関数を定義します。

typescript// usecases/useCreateUser.ts

import { useSetAtom } from 'jotai';
import { userListAtom } from '../atoms/userAtom';
import { User } from '../domain/User';

// ユーザー作成のユースケース
export const useCreateUser = () => {
  const setUserList = useSetAtom(userListAtom);

  return (id: string, name: string, email: string) => {
    // ドメイン層のファクトリメソッドを呼び出し
    const user = User.create(id, name, email);

    // atom の状態を更新
    setUserList((prev) => [...prev, user]);
  };
};

ユースケースは、ドメイン層の User.create メソッドを呼び出し、バリデーションエラーがあれば例外をスローします。バリデーションが成功すれば、atom の状態を更新します。

ユーザー削除のユースケース

typescript// usecases/useDeleteUser.ts

import { useSetAtom } from 'jotai';
import { userListAtom } from '../atoms/userAtom';

// ユーザー削除のユースケース
export const useDeleteUser = () => {
  const setUserList = useSetAtom(userListAtom);

  return (userId: string) => {
    // 指定された ID のユーザーを削除
    setUserList((prev) =>
      prev.filter((user) => user.id !== userId)
    );
  };
};

ユーザー削除のユースケースは、atom の状態を更新するだけで、ビジネスロジックは含みません。

UI コンポーネントの実装

UI コンポーネントは、ユースケースを呼び出し、atom を購読します。

typescript// components/UserForm.tsx

import React, { useState } from 'react';
import { useCreateUser } from '../usecases/useCreateUser';

export const UserForm: React.FC = () => {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const createUser = useCreateUser();

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    try {
      // ユースケースを呼び出し
      createUser(crypto.randomUUID(), name, email);
      setName('');
      setEmail('');
    } catch (error) {
      alert((error as Error).message);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type='text'
        placeholder='名前'
        value={name}
        onChange={(e) => setName(e.target.value)}
      />
      <input
        type='email'
        placeholder='メールアドレス'
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <button type='submit'>作成</button>
    </form>
  );
};

UI コンポーネントは、ユースケースを呼び出すだけで、バリデーションロジックの詳細を知る必要がありません。

ユーザーリスト表示コンポーネント

typescript// components/UserList.tsx

import React from 'react';
import { useAtomValue } from 'jotai';
import { userListAtom } from '../atoms/userAtom';
import { useDeleteUser } from '../usecases/useDeleteUser';

export const UserList: React.FC = () => {
  const userList = useAtomValue(userListAtom);
  const deleteUser = useDeleteUser();

  return (
    <ul>
      {userList.map((user) => (
        <li key={user.id}>
          {user.name} ({user.email})
          <button onClick={() => deleteUser(user.id)}>
            削除
          </button>
        </li>
      ))}
    </ul>
  );
};

ユーザーリスト表示コンポーネントは、atom を購読し、ユースケースを呼び出します。

以下の図は、ユーザー作成フローを示しています。

mermaidsequenceDiagram
  participant UI as UserForm
  participant UC as useCreateUser
  participant Domain as User.create
  participant Atom as userListAtom

  UI->>UC: createUser(id, name, email)
  UC->>Domain: User.create(id, name, email)
  Domain->>Domain: バリデーション実行
  alt バリデーション成功
    Domain-->>UC: User オブジェクト返却
    UC->>Atom: setUserList(追加)
    Atom-->>UI: 状態更新通知
  else バリデーション失敗
    Domain-->>UC: Error スロー
    UC-->>UI: Error スロー
  end

この図から、UI コンポーネントがユースケースを呼び出し、ユースケースがドメイン層のバリデーションを実行して atom の状態を更新する流れがわかります。

テストの実装

ドメイン層は純粋関数なので、テストが容易になります。

typescript// domain/User.test.ts

import { User } from './User';

describe('User', () => {
  test('正常なユーザーを作成できる', () => {
    const user = User.create(
      '1',
      '太郎',
      'taro@example.com'
    );
    expect(user.name).toBe('太郎');
    expect(user.email).toBe('taro@example.com');
  });

  test('名前が 1 文字の場合はエラー', () => {
    expect(() => {
      User.create('1', '太', 'taro@example.com');
    }).toThrow('名前は 2 文字以上である必要があります');
  });

  test('無効なメールアドレスの場合はエラー', () => {
    expect(() => {
      User.create('1', '太郎', 'invalid-email');
    }).toThrow('有効なメールアドレスを入力してください');
  });
});

ドメイン層のテストは、Jotai や React に依存しないため、シンプルで高速です。

ユースケース層のテスト

ユースケース層のテストは、Jotai のテストユーティリティを使って実装します。

typescript// usecases/useCreateUser.test.ts

import { renderHook, act } from '@testing-library/react';
import { useAtomValue } from 'jotai';
import { useCreateUser } from './useCreateUser';
import { userListAtom } from '../atoms/userAtom';

describe('useCreateUser', () => {
  test('ユーザーを作成できる', () => {
    const { result: createResult } = renderHook(() =>
      useCreateUser()
    );
    const { result: listResult } = renderHook(() =>
      useAtomValue(userListAtom)
    );

    act(() => {
      createResult.current('1', '太郎', 'taro@example.com');
    });

    expect(listResult.current).toHaveLength(1);
    expect(listResult.current[0].name).toBe('太郎');
  });
});

ユースケース層のテストでは、atom の状態変化を検証します。

設計の利点まとめ

この設計により、以下の利点が得られます。

#利点説明
1テストが容易ドメイン層は純粋関数なので、単体テストが簡単
2再利用性が高いビジネスロジックが atom に依存しないため、他のコンテキストでも利用可能
3保守性が向上ビジネスルールの変更がドメイン層に集約され、修正箇所が明確
4責務が明確各レイヤーの責務が明確で、コードの見通しが良い

まとめ

本記事では、Jotai とドメイン駆動設計を組み合わせる際の設計原則と具体例を解説しました。

重要なポイントは以下の通りです。

  • atom は状態のコンテナに徹する: ビジネスロジックは含めず、状態の保持と派生状態の計算に専念します
  • ドメイン層にビジネスロジックを集約する: バリデーションや計算などのビジネスルールはドメイン層に実装し、atom や React に依存しない純粋関数として設計します
  • ユースケース層でドメインと atom を調整する: ユースケース層がドメインロジックを呼び出し、atom の状態を更新することで、UI コンポーネントはビジネスロジックの詳細を知る必要がなくなります
  • レイヤー分離により保守性が向上: 各レイヤーの責務を明確にすることで、テストが容易になり、ビジネスルールの変更時の影響範囲が限定されます

この設計パターンを採用することで、Jotai を用いたアプリケーションの保守性とテスタビリティが大きく向上するでしょう。ドメイン駆動設計の考え方を取り入れることで、ビジネスロジックが明確になり、アプリケーションの成長に柔軟に対応できるようになります。

ぜひ、あなたのプロジェクトでも Jotai とドメイン駆動設計の組み合わせを試してみてください。

関連リンク