ドメインイベント — 集約間を疎結合に連携する
ドメインイベントとは
前章ではドメインサービスを学びました。本章では、ドメインで発生した重要な出来事を表現する「ドメインイベント」について学びます。
イベント駆動アーキテクチャの基礎
ドメインイベントは、イベント駆動アーキテクチャ(Event-Driven Architecture) の基本要素です。
ドメインイベントの特徴
ドメインイベントは以下の特徴を持ちます。
- 過去に起きた事実を表現する(過去形で命名:
OrderConfirmed,PaymentReceived) - 不変である(一度発生したら変更されない)
- 集約間の疎結合な連携を実現する
- ビジネス上重要な出来事を表現する
注文確定というイベントが発生し、複数のリスナーが独立して反応します(メール送信、在庫減少など)。
なぜドメインイベントを使うのか
疎結合(Loose Coupling)のメリット
ドメインイベントを使う最大の理由は疎結合です。疎結合により以下のメリットが得られます。
- 拡張性: 新しい機能をリスナー追加だけで実現(既存コード修正不要)
- 保守性: 各処理が独立しているため、影響範囲が限定される
- テスタビリティ: 各リスナーを個別にテスト可能
- 単一責任: UseCaseが肥大化せず、シンプルに保たれる
- 再利用性: 同じイベントに複数の異なる処理を紐付けられる
具体例:EC サイトでの注文確定
注文が確定したら、以下の処理が必要です:
- 在庫を減らす
- 確認メールを送る
- 配送部門に通知
- 売上レポートを更新
- ポイントを付与
これを密結合で実装すると、すべての処理がUseCaseに詰め込まれます。疎結合(イベント駆動)にすると、各処理が独立したリスナーになり、追加・削除・変更が容易になります。
問題:密結合なコード
// 問題のあるコード:すべての処理がUseCaseに密結合
final class ConfirmOrderUseCase
{
public function execute(OrderId $orderId): void
{
DB::transaction(function () use ($orderId) {
$order = $this->orderRepository->findById($orderId);
$order->confirm();
$this->orderRepository->save($order);
// 以下の処理がすべて密結合
$this->decreaseInventory($order); // 在庫を減らす
$this->sendConfirmationEmail($order); // メール送信
$this->notifyShippingDepartment($order); // 配送部門に通知
$this->updateSalesReport($order); // 売上レポート更新
$this->grantLoyaltyPoints($order); // ポイント付与
});
}
}
// 問題点:
// 1. UseCaseが肥大化する
// 2. 新しい処理を追加するたびにUseCaseを修正
// 3. テストが困難(すべての依存をモック)
// 4. 単一責任の原則に違反
解決:ドメインイベントによる疎結合
// ドメインイベントを使った設計
final class ConfirmOrderUseCase
{
public function execute(OrderId $orderId): void
{
DB::transaction(function () use ($orderId) {
$order = $this->orderRepository->findById($orderId);
$order->confirm(); // 内部でOrderConfirmedイベントを発行
$this->orderRepository->save($order);
});
// イベントはリスナーが個別に処理
// - InventoryDecrementListener
// - OrderConfirmationMailListener
// - ShippingNotificationListener
// - SalesReportListener
// - LoyaltyPointListener
}
}
// メリット:
// 1. UseCaseはシンプルに保たれる
// 2. 新しい処理はリスナーを追加するだけ
// 3. 各リスナーを独立してテスト可能
// 4. 単一責任の原則を守れる
ドメインイベントの実装
ドメインイベントの実装には2つのフェーズがあります。
この2フェーズ方式により、トランザクションが失敗してもイベントは発行されないという安全性が確保されます。
イベントクラスの定義
イベントは不変オブジェクトとして定義します。
// app/Domain/Order/Event/OrderConfirmed.php
final class OrderConfirmed
{
/**
* 注文が確定されたことを表すドメインイベント
*
* 不変オブジェクトとして定義(readonly プロパティ)
* 過去形で命名(Confirmed = 確定された)
*/
public function __construct(
public readonly OrderId $orderId,
public readonly array $orderLineIds, // リスナーが必要とする情報を含める
public readonly Money $totalAmount,
public readonly DateTimeImmutable $confirmedAt,
) {}
}
// app/Domain/Order/Event/OrderCancelled.php
final class OrderCancelled
{
/**
* 注文がキャンセルされたことを表すドメインイベント
*/
public function __construct(
public readonly OrderId $orderId,
public readonly string $reason, // キャンセル理由も含める
public readonly DateTimeImmutable $cancelledAt,
) {}
}
// app/Domain/Order/Event/OrderShipped.php
final class OrderShipped
{
/**
* 注文が発送されたことを表すドメインイベント
*/
public function __construct(
public readonly OrderId $orderId,
public readonly string $trackingNumber, // 追跡番号を含める
public readonly DateTimeImmutable $shippedAt,
) {}
}
イベントには、リスナーが処理を完了するために必要な情報を含めます。ただし、すべての情報を含める必要はありません。リスナーが必要に応じてリポジトリから追加情報を取得できます。
- 最小限の情報:ID のみ(リスナーがリポジトリで取得)
- 推奨:処理に必要な主要情報(追加クエリを減らす)
- 避ける:エンティティ全体の埋め込み(イベントが大きくなりすぎる)
エンティティからイベントを発行(Phase 1: 記録)
エンティティは状態変更時にイベントを記録します(まだディスパッチしない)。
// app/Domain/Order/Order.php
final class Order
{
/** @var object[] ドメインイベントを一時的に記録する配列 */
private array $domainEvents = [];
/**
* 注文を確定する
*
* ビジネスルール:
* - 確定可能な状態でなければならない
* - 注文明細が1つ以上必要
*/
public function confirm(): void
{
// ビジネスルールのバリデーション
if (!$this->status->canBeConfirmed()) {
throw new DomainException('この注文は確定できません');
}
if (empty($this->orderLines)) {
throw new DomainException('注文明細が空の状態では確定できません');
}
// 状態を変更
$this->status = OrderStatus::CONFIRMED;
// ドメインイベントを記録(Phase 1)
// まだディスパッチしない(トランザクション中)
$this->recordEvent(new OrderConfirmed(
$this->id,
array_map(fn($line) => $line->id(), $this->orderLines),
$this->totalAmount(),
new DateTimeImmutable(),
));
}
/**
* 注文をキャンセルする
*/
public function cancel(string $reason): void
{
if (!$this->status->canBeCancelled()) {
throw new DomainException('この注文はキャンセルできません');
}
$this->status = OrderStatus::CANCELLED;
// イベントを記録
$this->recordEvent(new OrderCancelled(
$this->id,
$reason,
new DateTimeImmutable(),
));
}
/**
* イベントを内部配列に記録
*/
private function recordEvent(object $event): void
{
$this->domainEvents[] = $event;
}
/**
* 記録されたイベントを取得し、クリアする
* リポジトリがこのメソッドを呼んでイベントをディスパッチする
*
* @return object[]
*/
public function pullDomainEvents(): array
{
$events = $this->domainEvents;
$this->domainEvents = []; // 取得後はクリア
return $events;
}
}
共通のトレイトとして実装
複数のエンティティでイベント発行を使う場合は、トレイトにまとめます。
// app/Domain/Shared/HasDomainEvents.php
trait HasDomainEvents
{
/** @var object[] */
private array $domainEvents = [];
protected function recordEvent(object $event): void
{
$this->domainEvents[] = $event;
}
/** @return object[] */
public function pullDomainEvents(): array
{
$events = $this->domainEvents;
$this->domainEvents = [];
return $events;
}
public function hasDomainEvents(): bool
{
return !empty($this->domainEvents);
}
}
// 使用例
final class Order
{
use HasDomainEvents;
public function confirm(): void
{
// ...
$this->recordEvent(new OrderConfirmed(...));
}
}
Laravelのイベントシステムとの統合
Laravelには強力なイベントシステムが組み込まれています。ドメインイベントをLaravelのイベントシステムと統合することで、既存のインフラを活用できます。
Laravel Eventsとの関係
Laravelの公式ドキュメント:Laravel Events
イベントディスパッチャーの作成(Phase 2: ディスパッチ)
// app/Infrastructure/Event/LaravelDomainEventDispatcher.php
final class LaravelDomainEventDispatcher implements DomainEventDispatcherInterface
{
/**
* 単一のドメインイベントをディスパッチ
*
* Laravelの event() ヘルパーを使って配信
*/
public function dispatch(object $event): void
{
// Laravelのイベントシステムにディスパッチ
event($event);
}
/**
* 複数のドメインイベントをまとめてディスパッチ
*
* リポジトリがエンティティから pull したイベントを渡す
*/
public function dispatchAll(array $events): void
{
foreach ($events as $event) {
$this->dispatch($event);
}
}
}
// app/Domain/Shared/DomainEventDispatcherInterface.php
interface DomainEventDispatcherInterface
{
/**
* ドメインイベントをディスパッチする
*
* インフラ層の実装(LaravelDomainEventDispatcher)が
* Laravel Eventsと統合する
*/
public function dispatch(object $event): void;
public function dispatchAll(array $events): void;
}
リポジトリでイベントをディスパッチ
リポジトリは、トランザクション成功後にイベントをディスパッチします。
// app/Infrastructure/Repository/EloquentOrderRepository.php
final class EloquentOrderRepository implements OrderRepositoryInterface
{
public function __construct(
private readonly DomainEventDispatcherInterface $eventDispatcher
) {}
/**
* 注文を保存し、ドメインイベントをディスパッチ
*
* 処理フロー:
* 1. トランザクション内でDBに保存
* 2. トランザクション成功後にイベントをディスパッチ
*
* この順序により、DB保存が失敗したらイベントも発行されない
*/
public function save(Order $order): void
{
// Phase 1: トランザクション内で永続化
DB::transaction(function () use ($order) {
// 1. エンティティをDBに保存
$orderModel = OrderModel::updateOrCreate(
['id' => $order->id()->value()],
[
'status' => $order->status()->value,
'total_amount' => $order->totalAmount()->amount(),
// ...
]
);
// 注文明細も保存
$this->saveOrderLines($orderModel, $order->orderLines());
});
// トランザクション成功(ここまで来たら確定)
// Phase 2: トランザクション完了後にイベントをディスパッチ
// エンティティから記録されたイベントを取得
$events = $order->pullDomainEvents();
// イベントディスパッチャーに渡す(Laravel Eventsで配信)
$this->eventDispatcher->dispatchAll($events);
}
private function saveOrderLines(OrderModel $orderModel, array $orderLines): void
{
// 注文明細の保存処理
// ...
}
}
トランザクション内でイベントをディスパッチすると、以下の問題が発生します:
- イベントリスナーが実行される
- その後トランザクションがロールバックされる
- イベントは発行されたのにDBには反映されない(不整合)
トランザクション成功後にディスパッチすることで、DBに保存されたことが確定してからイベントが発行されるため、整合性が保たれます。
上記の save() は、リポジトリ内のトランザクションを抜けた直後にディスパッチします。これは save() が最外のトランザクションのとき(単一集約の更新)だけ正しく機能します。
第15章のように UseCase が外側の DB::transaction() で複数集約をまとめる場合、リポジトリ内のトランザクションは SAVEPOINT であり実コミットではありません。そのため save() がディスパッチした時点では外側トランザクションは未コミットで、その後ロールバックされてもイベントは発行済みになり、ここで避けたかった不整合が起きます。
複数集約をまたぐ場合は、Laravel の DB::afterCommit() や、イベント/リスナーの ShouldDispatchAfterCommit を使い、最外コミットの完了後にディスパッチしてください。
// 最外トランザクションのコミット後に初めてディスパッチする
DB::afterCommit(fn () => $this->eventDispatcher->dispatchAll($events));
リスナーの実装
リスナーは、イベントに反応して特定の処理を実行します。各リスナーは単一責任を持ちます。
// app/Application/Listener/SendOrderConfirmationEmail.php
final class SendOrderConfirmationEmail
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
private readonly Mailer $mailer,
) {}
/**
* OrderConfirmedイベントを処理
*
* 責務:注文確定メールを送信する
*/
public function handle(OrderConfirmed $event): void
{
// イベントから注文IDを取得し、詳細情報をリポジトリから取得
$order = $this->orderRepository->findById($event->orderId);
// 顧客にメールを送信
$this->mailer->to($order->customerEmail())
->send(new OrderConfirmationMail($order));
}
}
// app/Application/Listener/DecreaseInventoryOnOrderConfirmed.php
final class DecreaseInventoryOnOrderConfirmed
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
private readonly InventoryRepositoryInterface $inventoryRepository,
) {}
/**
* OrderConfirmedイベントを処理
*
* 責務:注文が確定されたら在庫を減らす
*/
public function handle(OrderConfirmed $event): void
{
// 注文情報を取得
$order = $this->orderRepository->findById($event->orderId);
// 各注文明細の商品について在庫を減らす
foreach ($order->orderLines() as $line) {
$inventory = $this->inventoryRepository->findByProductId($line->productId());
// エンティティのビジネスロジックで在庫減少
$inventory->decrease($line->quantity());
// 変更を永続化
$this->inventoryRepository->save($inventory);
}
}
}
// app/Application/Listener/GrantLoyaltyPointsOnOrderConfirmed.php
final class GrantLoyaltyPointsOnOrderConfirmed
{
/**
* OrderConfirmedイベントを処理
*
* 責務:注文確定時にロイヤルティポイントを付与
*/
public function handle(OrderConfirmed $event): void
{
// イベントから直接情報を取得(追加クエリ不要)
// ビジネスルール:購入額の1%をポイント付与(小数点以下は切り捨て)
$points = (int) floor($event->totalAmount->amount() * 0.01);
// ポイント付与処理
// ...
}
}
リスナーの特徴:
- 単一責任: 1つのリスナーは1つの処理のみを担当
- 独立性: 他のリスナーの存在を知らない
- 疎結合: イベント発行元(Order)を知らない
イベントとリスナーの登録(AppServiceProvider)
Laravel 11 で EventServiceProvider($listen プロパティ)は廃止されました。イベントとリスナーの対応は AppServiceProvider::boot() 内で Event::listen() を使って登録します(参考: Laravel 11 Events)。リスナーの handle() 引数を型付けしておけば、イベント自動検出に任せて登録を省略できます。
// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Event;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
// 1つのイベントに複数のリスナーを登録する場合は Event::listen を複数回呼ぶ
Event::listen(OrderConfirmed::class, SendOrderConfirmationEmail::class);
Event::listen(OrderConfirmed::class, DecreaseInventoryOnOrderConfirmed::class);
Event::listen(OrderConfirmed::class, GrantLoyaltyPointsOnOrderConfirmed::class);
Event::listen(OrderCancelled::class, SendOrderCancellationEmail::class);
Event::listen(OrderCancelled::class, RestoreInventoryOnOrderCancelled::class);
Event::listen(OrderShipped::class, SendShippingNotificationEmail::class);
}
}
同期 vs 非同期
同期処理
デフォルトでは、イベントは同期的に処理されます。
同期処理ではすべてが同一リクエスト内で完了します。
非同期処理(キュー)
メール送信など時間のかかる処理は、キューを使って非同期化できます。
// app/Application/Listener/SendOrderConfirmationEmail.php
final class SendOrderConfirmationEmail implements ShouldQueue // キューで実行
{
use Queueable;
public function handle(OrderConfirmed $event): void
{
// この処理はキューワーカーが実行
$order = $this->orderRepository->findById($event->orderId);
$this->mailer->to($order->customerEmail())
->send(new OrderConfirmationMail($order));
}
}
レスポンスはすぐに返り、重い処理は後からキューワーカーが実行します。
| 処理タイプ | ユースケース | 実装 |
|---|---|---|
| 同期 | 在庫引当(即時整合性が必要) | 通常のリスナー |
| 非同期 | メール送信、通知 | ShouldQueue 実装 |
イベントソーシングとの違い
注意: ドメインイベントとイベントソーシングは異なる概念です。
- ドメインイベント: 集約間の通知・連携に使用。状態はエンティティが保持。
- イベントソーシング: イベントの履歴から状態を再構築。イベントが唯一の真実。
本書で扱うのはドメインイベントであり、イベントソーシングは対象外です。
冪等性(Idempotency)の確保
イベントリスナーは同じイベントが複数回処理されても問題ないように設計すべきです。
// NG: 冪等でないリスナー(重複実行で問題発生)
final class GrantLoyaltyPointsOnOrderConfirmed
{
public function handle(OrderConfirmed $event): void
{
// イベントの totalAmount からポイントを算出(購入額の1%)
$points = (int) floor($event->totalAmount->amount() * 0.01);
// 毎回ポイントを加算してしまう(顧客は注文IDから解決する想定)
$this->pointService->addPointsForOrder($event->orderId, $points);
}
}
// OK: 冪等なリスナー
final class GrantLoyaltyPointsOnOrderConfirmed
{
public function handle(OrderConfirmed $event): void
{
// 既に処理済みかチェック
if ($this->pointHistory->existsByOrderId($event->orderId)) {
return; // 処理済みならスキップ
}
$points = (int) floor($event->totalAmount->amount() * 0.01);
$this->pointService->addPointsForOrder($event->orderId, $points);
$this->pointHistory->recordProcessed($event->orderId);
}
}
- キューワーカーのリトライで同じイベントが再処理される可能性がある
- 分散システムでは「最低1回」の配信保証が一般的
- 冪等でないリスナーは重複処理でデータ不整合を起こす
結果整合性の設計
ドメインイベントを使うと、即時整合性と結果整合性を選択できます。
| 整合性モデル | 特徴 | 使いどころ |
|---|---|---|
| 即時整合性 | 同一トランザクション | 厳密な一貫性が必要(決済など) |
| 結果整合性 | 非同期イベント | 多少の遅延が許容される(通知、レポート) |
結果整合性の注意点
// 結果整合性を使う場合の考慮事項
final class DecreaseInventoryOnOrderConfirmed implements ShouldQueue
{
public function handle(OrderConfirmed $event): void
{
// 注意:このリスナーが失敗した場合、
// 注文は確定済みだが在庫は減っていない状態になる
// 対策1: リトライ設定
// 対策2: 失敗時の補償トランザクション
// 対策3: Dead Letter Queue での監視
}
public int $tries = 3; // 最大3回リトライ
public function failed(OrderConfirmed $event, \Throwable $e): void
{
// 失敗時の通知・補償処理
Log::error('在庫減算失敗', ['orderId' => $event->orderId->value()]);
}
}
ドメインイベントの設計指針
| 指針 | 説明 |
|---|---|
| 過去形で命名 | OrderConfirmed, PaymentReceived など |
| 必要な情報を含める | リスナーが追加のクエリなしで処理できるように |
| 不変にする | readonly プロパティで定義 |
| トランザクション後にディスパッチ | 永続化が成功してからイベントを発行 |
まとめ
重要ポイント
| ポイント | 説明 |
|---|---|
| ドメインイベントの役割 | ドメインで起きた重要な出来事を表現し、集約間の疎結合な連携を実現 |
| イベント駆動の利点 | 拡張性、保守性、テスタビリティが向上 |
| 2フェーズ実装 | Phase 1: 記録(エンティティ内)、Phase 2: ディスパッチ(リポジトリ) |
| Laravelとの統合 | event() ヘルパーとリスナーで実現 |
| 同期/非同期 | ShouldQueue で非同期処理が可能 |
| 整合性の選択 | 即時整合性 vs 結果整合性を適切に選ぶ |
ドメインイベントを使うべき場面
✓ 使うべき:
- 集約間の連携(注文確定→在庫減少)
- 副次的な処理(注文確定→メール送信)
- 拡張可能な処理(新しいリスナーを追加する可能性がある)
✗ 使わない:
- 単一集約内の処理(エンティティのメソッドで完結)
- 即時整合性が必須の処理(同一トランザクション内で完了すべき)
- 処理の順序保証が重要(イベントは順序保証なし)
参照リソース
ドメインイベントについてさらに学ぶための資料:
- Laravel Events(公式ドキュメント) - Laravelのイベントシステムの詳細
- Martin Fowler「Domain Event」 - ドメインイベントの概念的な説明
- Event-Driven Architecture - イベント駆動アーキテクチャの全体像
次のステップ
ドメインイベントは強力だが複雑なパターンです。最初は同期処理で実装し、必要に応じて非同期化やイベントソーシングを検討するのが良いでしょう。
次のチャプターでは、Laravelでのクリーンアーキテクチャの実践について詳しく見ていきます。