ドメインイベント: 集約間の疎結合な連携
ドメインイベントとは
前章ではドメインサービスを学びました。本章では、ドメインで発生した重要な出来事を表現する「ドメインイベント」について学びます。
イベント駆動アーキテクチャの基礎
ドメインイベントは、イベント駆動アーキテクチャ(Event-Driven Architecture) の基本要素です。
ドメインイベントの特徴
ドメインイベントは以下の特徴を持ちます。
- 過去に起きた事実を表現する(過去形で命名:
OrderConfirmed,PaymentReceived) - 不変である(一度発生したら変更されない)
- 集約間の疎結合な連携を実現する
- ビジネス上重要な出来事を表現する
注文確定というイベントが発生し、複数のリスナーが独立して反応します(メール送信、在庫減少など)。
なぜドメインイベントを使うのか
疎結合(Loose Coupling)のメリット
ドメインイベントを使う最大の理由は疎結合です。
- 拡張性: 新しい機能をリスナー追加だけで実現(既存コード修正不要)
- 保守性: 各処理が独立しているため、影響範囲が限定される
- テスタビリティ: 各リスナーを個別にテスト可能
- 単一責任: UseCase が肥大化せず、シンプルに保たれる
- 再利用性: 同じイベントに複数の異なる処理を紐付けられる
具体例:EC サイトでの注文確定
注文が確定したら、以下の処理が必要です:
- 在庫を減らす
- 確認メールを送る
- 配送部門に通知する
- 売上レポートを更新する
- ポイントを付与する
これを密結合で実装すると、すべての処理が UseCase に詰め込まれます。疎結合(イベント駆動)にすると、各処理が独立したリスナーになり、追加・削除・変更が容易になります。
問題:密結合なコード
// 問題のあるコード:すべての処理がUseCaseに密結合
final class ConfirmOrderUseCase
{
public function execute(ConfirmOrderCommand $command): void
{
DB::transaction(function () use ($command) {
$order = $this->orderRepository->findById(new OrderId($command->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(ConfirmOrderCommand $command): void
{
DB::transaction(function () use ($command) {
$order = $this->orderRepository->findById(new OrderId($command->orderId));
$order->confirm(); // 内部でOrderConfirmedイベントを発行
$this->orderRepository->save($order);
});
// イベントはリスナーが個別に処理
// - InventoryDecrementListener
// - OrderConfirmationMailListener
// - ShippingNotificationListener
// - SalesReportListener
// - LoyaltyPointListener
}
}
// メリット:
// 1. UseCaseはシンプルに保たれる
// 2. 新しい処理はリスナーを追加するだけ
// 3. 各リスナーを独立してテスト可能
// 4. 単一責任の原則を守れる
ドメインイベントの実装
ドメインイベントの実装には2つのフェーズがあります。
save() が最外のトランザクションである限り、この 2 フェーズ方式ならトランザクションが失敗してもイベントは発行されません。UseCase 側でさらに外側の DB::transaction() で包む場合は事情が変わります(後述の警告「ネストしたトランザクションでは『最外コミット後』にディスパッチする」を参照)。
イベントクラスの定義
イベントは不変オブジェクトとして定義します。
// 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
use App\Domain\Order\Exception\InvalidOrderStateException;
final class Order
{
/** @var object[] ドメインイベントを一時的に記録する配列 */
private array $domainEvents = [];
/**
* 注文を確定する
*
* ビジネスルール:
* - 確定可能な状態でなければならない
* - 注文明細が1つ以上必要
*/
public function confirm(): void
{
// ビジネスルールのバリデーション
if (!$this->status->canBeConfirmed()) {
throw new InvalidOrderStateException('この注文は確定できません');
}
if (empty($this->orderLines)) {
throw new InvalidOrderStateException('注文明細が空の状態では確定できません');
}
// 状態を変更
$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 InvalidOrderStateException('この注文はキャンセルできません');
}
$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
{
// save() とその補助メソッドのみ抜粋
// interface の他のメソッド(create() / findById() / nextIdentity() / nextLineIdentity())は第17章
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::findOrNew($order->id()->value());
// 識別子は$fillableに含めず直接代入する(第17章)
$orderModel->id = $order->id()->value();
$orderModel->fill([
'status' => $order->status()->value,
'total_amount' => $order->totalAmount()->amount(),
// ...
])->save();
// 注文明細も保存
$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() が最外のトランザクションのとき(単一集約の更新)だけ正しく機能します。
第19章のように UseCase が外側の DB::transaction() で複数集約をまとめる場合、リポジトリ内のトランザクションは SAVEPOINT であり実コミットではありません。そのため save() がディスパッチした時点では外側トランザクションは未コミットで、その後ロールバックされてもイベントは発行済みになり、ここで避けたかった不整合が起きます。
複数集約をまたぐ場合は、Laravel の DB::afterCommit() を使うか、イベントクラスに ShouldDispatchAfterCommit を実装して、最外コミットの完了後にディスパッチしてください。キュー投入するリスナー側で同じことをしたい場合は、リスナークラスに ShouldQueue を実装して public $afterCommit = true; を置きます。
// 最外トランザクションのコミット後に初めてディスパッチする
DB::afterCommit(fn () => $this->eventDispatcher->dispatchAll($events));
リスナーの実装
リスナーは、イベントに反応して特定の処理を実行します。各リスナーは単一責任を持ちます。
// app/Application/Listener/SendOrderConfirmationEmail.php
final class SendOrderConfirmationEmail
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
private readonly CustomerRepositoryInterface $customerRepository,
private readonly Mailer $mailer,
) {}
/**
* OrderConfirmedイベントを処理
*
* 責務:注文確定メールを送信する
*/
public function handle(OrderConfirmed $event): void
{
// イベントから注文IDを取得し、詳細情報をリポジトリから取得
$order = $this->orderRepository->findById($event->orderId);
// 注文は顧客を保持しないので、顧客は注文IDから引く(第11章)
$customer = $this->customerRepository->findByOrderId($event->orderId);
// 顧客にメールを送信
$this->mailer->to($customer->email()->value())
->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);
// ポイント付与処理
// ...
}
}
同じイベントが二度届いてもポイントを二重に付与しないための工夫は、後述の「冪等性(Idempotency)の確保」で扱います。
リスナーの特徴:
- 単一責任: 1 つのリスナーは 1 つの処理のみを担当
- 独立性: 他のリスナーの存在を知らない
- 疎結合: イベント発行元(Order)を知らない
イベントとリスナーの登録(AppServiceProvider)
Laravel 11 の既定スケルトンから app/Providers/EventServiceProvider.php が無くなりました。フレームワーク側の Illuminate\Foundation\Support\Providers\EventServiceProvider と $listen の仕組みは残っているので、Laravel 10 の構成のままアップグレードしたアプリでは引き続き動きます。新しい構成では、イベントとリスナーの対応を AppServiceProvider::boot() 内で Event::listen() を使って登録します(参考: Laravel 11 Events)。
なお、リスナーの handle() 引数を型付けしておけば自動検出に任せられますが、自動検出が走査するのは app/Listeners だけです。本ガイドのように app/Application/Listener/ へ置く場合は bootstrap/app.php の withEvents(discover: [...]) で走査先を指定するか、上記のとおり明示的に登録してください。
// app/Providers/AppServiceProvider.php
namespace App\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
// イベントとリスナーの use は省略
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
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
final class SendOrderConfirmationEmail implements ShouldQueue // キューで実行
{
use InteractsWithQueue;
public function handle(OrderConfirmed $event): void
{
// この処理はキューワーカーが実行
$order = $this->orderRepository->findById($event->orderId);
$customer = $this->customerRepository->findByOrderId($event->orderId);
$this->mailer->to($customer->email()->value())
->send(new OrderConfirmationMail($order));
}
}
レスポンスはすぐに返り、重い処理は後からキューワーカーが実行します。
| 処理タイプ | 使いどころ | 実装 |
|---|---|---|
| 同期 | 在庫引当(即時整合性が必要) | 通常のリスナー |
| 非同期 | メール送信、通知 | ShouldQueue 実装 |
イベントソーシングとの違い
注意: ドメインイベントとイベントソーシングは異なる概念です。
- ドメインイベント: 集約間の通知・連携に使用。状態はエンティティが保持。
- イベントソーシング: イベントの履歴から状態を再構築。イベントが唯一の真実。
違いは「何を保存するか」に表れます。
ドメインイベントではordersテーブルに現在の状態を保存し、イベントは通知の手段として流れて消えます。
イベントソーシングではOrderPlaced・OrderConfirmed・OrderCancelledといったイベントを追記していき、現在の状態はそれらを順に再生して組み立てます。
注文の現在の状態を持つテーブルは書き込みの側に存在せず、正本はイベントの列だけになります。
得られるのは監査です。 「いつ何が起きたか」がデータ構造そのものになるため、履歴を後から設計して足す必要がありません。 過去のある時点の状態も、そこまでのイベントを再生すれば再現できます。
代償は 3 つあり、どれも避けて通れません。
- 問い合わせが難しい: 「キャンセル済みの注文を一覧する」ようなクエリがそのままでは書けません。読み取り専用のテーブルを別に用意し、イベントから更新し続ける必要があります。CQRS がほぼセットで語られるのはこのためです(第 23 章)
- イベントを変更できない: 追記したイベントは起きた事実の記録なので、後から書き換えられません。仕様変更でイベントの形が変わると、古い形式を読み続ける仕組みを残すことになります
- 再生に時間がかかる: イベントが積み上がるほど再生が遅くなるため、一定間隔で状態のスナップショットを保存する仕組みが要ります
採否の分かれ目は、履歴そのものが業務要件かどうかです。 会計・在庫・医療記録のように「なぜ今この状態なのか」を後から証明する必要があるなら、検討に値します。 そうでなければ、ドメインイベントで通知を分離し、必要な履歴は監査ログのテーブルに書くほうが安く済みます。 通しの題材である注文システムの実装が採るのはこちらです。イベントソーシングを実際に組む形は第 23 章で扱います。
冪等性(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 で非同期処理が可能 |
| 整合性の選択 | 厳密な一貫性が要るなら即時整合性、遅延を許容できるなら結果整合性 |
ドメインイベントを使うべき場面
✓ 使うべき:
- 集約間の連携(注文確定→在庫減少)
- 副次的な処理(注文確定→メール送信)
- 拡張可能な処理(新しいリスナーを追加する可能性がある)
✗ 使わない:
- 単一集約内の処理(エンティティのメソッドで完結)
- 即時整合性が必須の処理(同一トランザクション内で完了すべき)
- 処理の順序保証が重要(イベントは順序保証なし)
参照リソース
ドメインイベントについてさらに学ぶための資料:
- Laravel Events(公式ドキュメント) - Laravel のイベントシステムの詳細
- Martin Fowler「Domain Event」 - ドメインイベントの概念的な説明
- Event-Driven Architecture - イベント駆動アーキテクチャの全体像
- Martin Fowler「Event Sourcing」 - イベントソーシングの原典的な解説
次のステップ
ドメインイベントは強力だが複雑なパターンです。最初は同期処理で実装し、必要に応じて非同期化やイベントソーシングを検討するのが良いでしょう。
次章では第 2 部の締めくくりとして、ここまでの戦術パターンをオブジェクト指向の原則の側から読み直します。