このドキュメントでは、ドメイン駆動設計におけるドメインイベントの導入と、Effect-TSを用いた実装方法について説明します。
- 型安全なエラーハンドリング:
Effect.Effect<成功型, エラー型, 依存型>により、発生しうるエラーが型レベルで明示され、コンパイル時にエラー処理の漏れを防げます - 依存性注入:
Context.Tagを使ったDIにより、テスト時にモックへの差し替えが容易
ドメインロジック(状態遷移)が、新しい状態だけでなくドメインイベントも生成するように変更します。また、switch文に網羅性チェックを追加します。
import { Schema, Effect, Context, Brand, Data } from "effect";
// --- データ構造 (スキーマ) ---
// 【Effect】Brand型: 実行時のバリデーションと型安全性を両立
type NonEmptyString = string & Brand.Brand<"NonEmptyString">;
const NonEmptyString = Schema.String.pipe(Schema.minLength(1), Schema.brand("NonEmptyString"));
// 【Effect】TaskId専用のBrand型: IDの型安全性を保証
type TaskId = string & Brand.Brand<"TaskId">;
const TaskId = Schema.String.pipe(
Schema.minLength(1, { message: () => "Task ID cannot be empty" }),
Schema.brand("TaskId")
);
// 【Effect】Schema.Class: 型安全なデータクラスの定義
// Tagged Union(ADT)により、状態を型レベルで表現
class Todo extends Schema.Class<Todo>()({ _tag: Schema.Literal("Todo"), createdAt: Schema.Date }) {}
class InProgress extends Schema.Class<InProgress>()({ _tag: Schema.Literal("InProgress"), startedAt: Schema.Date }) {}
class Done extends Schema.Class<Done>()({ _tag: Schema.Literal("Done"), completedAt: Schema.Date }) {}
export const TaskStatus = Schema.Union(Todo, InProgress, Done);
export type TaskStatus = Schema.To<typeof TaskStatus>;
// 【Effect】Data.Class: イミュータブルなデータ構造を簡潔に定義
export class Task extends Data.Class<{
readonly id: TaskId;
readonly title: NonEmptyString;
readonly status: TaskStatus;
}> {}
// --- ドメインイベント ---
// 【Effect】Data.Class: イミュータブルなデータ構造を簡潔に定義
class TaskCompleted extends Data.Class<{
readonly _tag: "TaskCompleted";
readonly taskId: TaskId; // TaskId型を使用
readonly completedAt: Date;
}> {}
export type DomainEvent = TaskCompleted;
// --- ドメインエラー ---
// 【Effect】Data.TaggedError: 型安全なエラークラス、パターンマッチング可能
export class TaskAlreadyCompletedError extends Data.TaggedError("TaskAlreadyCompletedError") {}
export class TaskNotInProgressError extends Data.TaggedError("TaskNotInProgressError") {}
type DomainError = TaskAlreadyCompletedError | TaskNotInProgressError;
// --- ドメインロジック (状態とイベントを返す) ---
// 【Effect】Effect.Effect<成功型, エラー型>: 型レベルでエラーハンドリングを強制
export const completeTask = (
task: Task
): Effect.Effect<readonly [Task, ReadonlyArray<DomainEvent>], DomainError> => {
switch (task.status._tag) {
case "Done":
// 【Effect】Effect.fail: 型安全なエラー返却
return Effect.fail(new TaskAlreadyCompletedError());
case "Todo":
return Effect.fail(new TaskNotInProgressError());
case "InProgress": {
const completedAt = new Date();
// 【Effect】Data.Class: 新しい状態のTaskをイミュータブルに生成
const updatedTask = new Task({ ...task, status: new Done({ completedAt }) });
// 生成されたドメインイベント
const event = new TaskCompleted({ taskId: task.id, completedAt });
// 【Effect】Effect.succeed: 成功結果を型安全に返却
return Effect.succeed([updatedTask, [event]]);
// 網羅性チェック: 将来新しい状態が追加され、caseが漏れているとコンパイルエラーになる
default: {
const _exhaustiveCheck: never = task.status;
// 【Effect】Effect.die: 回復不可能なエラー(プログラムのバグ)を表現
return Effect.die(_exhaustiveCheck);
}
}
};
// --- インフラ層との契約 ---
export class TaskNotFoundError extends Data.TaggedError("TaskNotFoundError") {}
export class SaveError extends Data.TaggedError("SaveError")<{ cause: unknown }> {}
export class PublishError extends Data.TaggedError("PublishError")<{ cause: unknown }> {}
export interface TaskRepository {
// 【Effect】各メソッドが発生しうるエラーを型で明示
readonly findById: (id: TaskId) => Effect.Effect<Task, TaskNotFoundError>;
readonly save: (task: Task) => Effect.Effect<void, SaveError>;
}
// 【Effect】Context.Tag: 依存性注入のためのタグ、テスト時にモック差し替えが容易
export const TaskRepository = Context.Tag<TaskRepository>();
export interface EventPublisher {
readonly publish: (events: ReadonlyArray<DomainEvent>) => Effect.Effect<void, PublishError>;
}
export const EventPublisher = Context.Tag<EventPublisher>();ユースケースにイベント発行のステップを追加します。saveとpublishは並行して実行できます。
import { Effect } from "effect";
import {
TaskRepository,
EventPublisher,
completeTask,
DomainError,
TaskNotFoundError,
SaveError,
PublishError
} from "./domain";
// 【Effect】Union型でアプリケーション層で扱うすべてのエラーを集約
type AppError = TaskNotFoundError | SaveError | PublishError | DomainError;
// 【Effect】Effect.Effect<成功型, エラー型, 依存型>で依存関係を型で明示
export const completeTaskUseCase = (taskId: string): Effect.Effect<Task, AppError, TaskRepository | EventPublisher> =>
// 【Effect】Effect.gen: do記法風の構文糖衣、非同期処理を同期的に記述
Effect.gen(function*() {
// 【Effect】依存性の注入、Context.Tagで定義されたサービスを取得
const repo = yield* TaskRepository;
const publisher = yield* EventPublisher;
// 【Effect】yield*でEffect値を展開、エラーハンドリングは自動的に上位に伝播
const task = yield* repo.findById(taskId);
// ドメインロジックは新しい状態とイベントを返す
const [updatedTask, events] = yield* completeTask(task);
// 【Effect】Effect.all: 複数のEffectを並行実行、どちらかが失敗すれば全体が失敗
// concurrency: "inherit"で親のコンカレンシー設定を継承
yield* Effect.all([
repo.save(updatedTask),
publisher.publish(events)
], { concurrency: "inherit" });
console.log(`[App] タスク「${updatedTask.title}」を正常に完了し、イベントを発行しました。`);
return updatedTask;
});EventPublisherの本番用Layerを追加します。
import { Layer, Effect } from "effect";
import {
Task, TaskRepository, TaskNotFoundError, SaveError,
EventPublisher, PublishError, DomainEvent,
InProgress
} from "./domain";
// -- Repository --
const db = new Map<TaskId, Task>([
["task-1" as TaskId, new Task({
id: "task-1" as TaskId,
title: "最終課題" as NonEmptyString,
status: new InProgress({ startedAt: new Date() })
})],
]);
// 【Effect】Layer.succeed: 依存性の具体的な実装を提供するLayer
// テスト時には異なるLayerに差し替え可能
export const LiveTaskRepository = Layer.succeed(TaskRepository, {
findById: (id) => db.has(id) ? Effect.succeed(db.get(id)!) : Effect.fail(new TaskNotFoundError()),
// 【Effect】Effect.sync: 同期的な副作用をEffectでラップ
save: (task) => Effect.sync(() => { db.set(task.id, task); }),
});
// -- Event Publisher --
export const LiveEventPublisher = Layer.succeed(EventPublisher, {
publish: (events) => Effect.sync(() => {
console.log(`[Event] ${events.length}件のイベントを発行:`, events.map(e => e._tag));
}),
});import { Effect, Layer } from "effect";
import { completeTaskUseCase } from "./usecase";
import { LiveTaskRepository, LiveEventPublisher } from "./infra";
const program = completeTaskUseCase("task-1" as TaskId);
// 【Effect】Layer.merge: 複数のLayerを結合して依存関係を解決
const AppLayer = Layer.merge(LiveTaskRepository, LiveEventPublisher);
// 【Effect】Effect.provide: プログラムに依存関係を注入して実行可能な状態にする
// Effect.runPromise: EffectをPromiseに変換して実行
Effect.runPromise(Effect.provide(program, AppLayer));テストでもEventPublisherのモックを提供し、イベントが正しく発行されたかを検証します。
import { describe, it, expect, vi, assert } from "vitest";
import { Effect, Exit, Layer } from "effect";
import { Task, TaskRepository, EventPublisher, InProgress } from "../src/domain";
import { completeTaskUseCase } from "../src/usecase";
// --- テスト用のモック実装 ---
const mockRepo = { findById: vi.fn(), save: vi.fn() };
// 【Effect】Layer.succeed: テスト用のモック実装をLayerとして定義
const TestTaskRepository = Layer.succeed(TaskRepository, mockRepo);
const mockPublisher = { publish: vi.fn() };
const TestEventPublisher = Layer.succeed(EventPublisher, mockPublisher);
// 【Effect】Layer.merge: テスト用のLayerを結合
const TestLayer = Layer.merge(TestTaskRepository, TestEventPublisher);
describe("completeTaskUseCase", () => {
it("正常系: タスクを完了し、ドメインイベントを発行する", async () => {
// Arrange
const task = new Task({
id: "task-1" as TaskId,
title: "テスト対象" as NonEmptyString,
status: new InProgress({ startedAt: new Date() })
});
// 【Effect】Effect.succeed/void: モックの戻り値をEffectで包む
mockRepo.findById.mockReturnValue(Effect.succeed(task));
mockRepo.save.mockReturnValue(Effect.void);
mockPublisher.publish.mockReturnValue(Effect.void);
// Act
const program = completeTaskUseCase("task-1" as TaskId);
// 【Effect】Effect.runPromiseExit: Effectを実行し、Exit(成功/失敗)を取得
const result = await Effect.runPromiseExit(Effect.provide(program, TestLayer));
// Assert
// 【Effect】Exit.match: 成功/失敗をパターンマッチングで処理
Exit.match(result, {
onSuccess: () => {
// 1. 保存が呼ばれたか
expect(mockRepo.save).toHaveBeenCalledTimes(1);
// 2. イベント発行が呼ばれたか
expect(mockPublisher.publish).toHaveBeenCalledTimes(1);
const publishedEvents = mockPublisher.publish.mock.calls[0][0];
// 3. 発行されたイベントの内容は正しいか
expect(publishedEvents).toHaveLength(1);
expect(publishedEvents[0]._tag).toBe("TaskCompleted");
expect(publishedEvents[0].taskId).toBe("task-1" as TaskId);
},
onFailure: (cause) => assert.fail(`テストが失敗しました: ${cause.pretty}`),
});
});
});この実装により、以下が実現されます:
- ドメインの豊かな表現: 状態変更だけでなく、何が起こったかをイベントとして表現
- 疎結合なアーキテクチャ: イベントを通じた非同期処理や他のコンテキストとの連携
- テスト容易性: モックを使った単体テストでイベント発行の検証が可能
- 型安全性: Effect-TSとTypeScriptの組み合わせによる堅牢な実装
ドメインイベント導入ガイド - Effect-TS実装
要約
このドキュメントでは、ドメイン駆動設計におけるドメインイベントの導入と、Effect-TSを用いた実装方法について説明します。
Effect-TSを使うポイント
Effect.Effect<成功型, エラー型, 依存型>により、発生しうるエラーが型レベルで明示され、コンパイル時にエラー処理の漏れを防げますContext.Tagを使ったDIにより、テスト時にモックへの差し替えが容易1. ドメイン層: イベント発行と網羅性チェックの追加
ドメインロジック(状態遷移)が、新しい状態だけでなくドメインイベントも生成するように変更します。また、switch文に網羅性チェックを追加します。
src/domain.ts
リトライ戦略の選択肢と実装場所
1. アプリケーション層でのリトライ(推奨)
ユースケース内でイベント発行のみにリトライを適用する方法:
アプリケーション層(ユースケース内)のリトライ実装 ⭐
基本的なリトライパターン
条件付きリトライパターン
段階的リトライパターン
イベント種別による異なるリトライ戦略
ログ付きリトライパターン
柔軟な設定可能リトライパターン
テスト用のリトライ戦略
これらのパターンから、要件に応じて適切なリトライ戦略を選択できます。アプリケーション層でリトライを実装することで、ビジネスロジックの一部として明示的にリトライ戦略を表現でき、テストも容易になります。
src/usecase.ts
2. インフラ層でのリトライ
EventPublisherの実装レベルでリトライを組み込む方法:
src/infra.ts
3. 呼び出し元でのエラーハンドリング
ユースケースの呼び出し側でリトライや代替処理を行う場合:
src/main.ts
推奨アプローチの選択指針
1. アプリケーション層(ユースケース内)- 最推奨 ⭐
メリット:
適用ケース:
2. 呼び出し元でのエラーハンドリング
メリット:
適用ケース:
3. インフラ層
メリット:
適用ケース:
実装の組み合わせ例
この設計により、堅牢で柔軟なイベント処理システムが構築できます。
2. アプリケーション層: イベント発行の追加とリトライ戦略
ユースケースにイベント発行のステップを追加します。saveとpublishは並行して実行できます。
src/usecase.ts
3. プロダクトコード: 本番用の実装と実行
EventPublisherの本番用Layerを追加します。
インフラ層(本番用)
src/infra.ts
エントリーポイント
src/main.ts
4. テストコード
テストでもEventPublisherのモックを提供し、イベントが正しく発行されたかを検証します。
tests/usecase.test.ts
まとめ
この実装により、以下が実現されます: