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 の状態を更新することがわかります。
レイヤー分離の原則
以下の表は、各レイヤーの責務を整理したものです。
# | レイヤー | 責務 | 依存先 | 例 |
---|---|---|---|---|
1 | UI コンポーネント | 表示とユーザー操作の受付 | ユースケース、atom | UserForm.tsx |
2 | ユースケース層 | ドメインロジックの呼び出しと atom の更新 | ドメイン層、atom | useUpdateUser.ts |
3 | Atom 層 | 状態の保持と派生状態の計算 | ドメイン層(参照のみ) | 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 とドメイン駆動設計の組み合わせを試してみてください。
関連リンク
- article
Jotai ドメイン駆動設計:ユースケースと atom の境界を引く実践
- article
Jotai クイックリファレンス:atom/read/write/derived の書き方早見表
- article
Jotai セットアップ完全レシピ:Vite/Next.js/React Native 横断対応
- article
Jotai 全体像を一枚で理解:Atom・派生・非同期の関係を図解
- article
状態遷移を明文化する:XState × Jotai の堅牢な非同期フロー設計
- article
Undo/Redo を備えた履歴つき状態管理を Jotai で設計する
- article
LangChain ハイブリッド検索設計:BM25 +ベクトル+再ランキングで精度を底上げ
- article
Apollo スキーマの進化設計:非破壊変更・ディプレケーション・ロールアウト戦略
- article
Jotai ドメイン駆動設計:ユースケースと atom の境界を引く実践
- article
Zod のブランド型(Branding)設計:メール・ULID・金額などの値オブジェクト化
- article
Web Components の API 設計原則:属性 vs プロパティ vs メソッドの境界線
- article
Jest を Yarn PnP で動かす:ゼロ‐node_modules 時代の設定レシピ
- 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 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来