ドメインイベントで副作用を分離する — 通知と集計を本体から外す
この記事で扱う問題
注文を確定する ConfirmOrder は、これまでの記事で、状態の変更・在庫の引き当て・出荷の依頼までを1つのトランザクションで行う形になっています。ここに、例えば次の要望が順に届いたとします。
- 確定したら、利用者に確定の通知メールを送りたい
- 管理画面に出す「日ごとの売上」を、確定のたびに更新したい
- 注文の詳細をキャッシュしているので、確定したら消したい
素直に足すと、次のようになります。
return $this->transaction->run(function () use ($id, $operatorId) {
$order = $this->orders->findOrFail($id);
$order->confirm($operatorId, $this->clock->now());
$this->allocateStocks($order);
$this->orders->save($order);
$this->shipmentRequests->enqueue($id);
$this->mailer->sendOrderConfirmed($id); // 確定の通知メール
$this->dailySales->add($this->clock->now(), $order->total()); // 日ごとの売上に加算
$this->cache->forget("order-detail:{$id->value}"); // 注文の詳細のキャッシュを消す
return ConfirmOrderResult::from($order);
});
動きます。ただ、例えば次のことが起こり得ます。
- 副作用の遅さが、本体のロックの時間になる:メールサーバーの応答を待つ間も、引き当てた在庫の行と注文の行はロックされたままです。人気の商品では、ほかの確定がメールの送信を待つことになります
- 副作用の失敗が、本体の失敗になる:メールの送信が例外を投げると、注文の確定がロールバックされます。逆に、メールを送った後で、売上の加算がロック待ちのタイムアウトやデッドロックで失敗したり、コミットそのものが失敗したりすると、ロールバックされた確定の通知が届きます
- 集計の行が、すべての確定を直列にする:日ごとの売上は1日1行です。同じトランザクションで加算すると、その日のすべての確定がこの1行のロックを順番待ちします
- キャッシュを消すのが早すぎる:コミットの前に消すと、その間に来た別のリクエストが、まだ確定前の注文を読んでキャッシュに入れ直します
- UseCase が副作用の一覧になる:要望が来るたびに、コンストラクタの引数と
execute()の行が増えます。「注文を確定する」という手順が、副作用の行に埋もれていきます
判断
本体の処理と同時に正しくなくてもよい副作用は、「注文が確定した」という事実(ドメインイベント)を記録し、それを受け取るハンドラーに任せます。本体は、誰がその事実を受け取るかを知りません。
そのうえで、副作用を次の3つに分けます。
| 副作用の性質 | 例 | 置き場所 |
|---|---|---|
| コミットの瞬間に本体と一緒に正しくなければならない | 在庫の引き当て | 副作用ではない。UseCase のトランザクションの中で明示的に呼ぶ |
| 後からでよいが、消えると困り、誰も気づかない | 出荷の依頼、ポイントの付与 | UseCase から Interface で明示的に依頼する(Outbox) |
| 後からでよく、まれに消えても気づける・作り直せる | 通知メール、集計、キャッシュの削除 | ドメインイベントのハンドラー(コミット後) |
この記事で扱うのは、3つ目です。
イベントは、状態を変えた集約が記録する
イベントは、業務の言葉で「起きたこと」を過去形で表す、ただの値のクラスにします。Laravel には依存させません。
interface DomainEvent
{
}
final readonly class OrderConfirmed implements DomainEvent
{
public function __construct(
public OrderId $orderId,
public int $confirmedBy,
public DateTimeImmutable $confirmedAt,
) {
}
}
Outbox の記事では、OrderConfirmed を Laravel のイベント(ShouldDispatchAfterCommit を実装し、注文 ID の文字列を持つ)として書き、どこから発行するかは決めていませんでした。この記事では、発行する責任を Order に持たせるので、Laravel に依存しない形に置き換えます。コミット後に配る役割は、ShouldDispatchAfterCommit の代わりに、後で書く発行の実装が持ちます。
Order には、記録したイベントを持つ配列と、それを取り出すメソッドを足し、confirm() の最後に1行足します。ほかの項目とメソッドは、既存の記事の定義のままです。
final class Order
{
// id・status・confirmedAt・confirmedBy・version・lines と、
// place()・reconstitute()・id()・version()・cancel()・addLine()・lines()・total() は既存の定義のまま(省略)
/** @var list<DomainEvent> */
private array $recordedEvents = [];
public function confirm(int $operatorId, DateTimeImmutable $now): void
{
if (! $this->status->canBeConfirmed()) {
throw new OrderCannotBeConfirmed($this->id, $this->status);
}
$this->status = OrderStatus::Confirmed;
$this->confirmedAt = $now;
$this->confirmedBy = $operatorId;
$this->recordedEvents[] = new OrderConfirmed($this->id, $operatorId, $now);
}
/**
* 記録したイベントを返し、手元からは消す。保存に成功した後で1回だけ呼ぶ
*
* @return list<DomainEvent>
*/
public function pullEvents(): array
{
$events = $this->recordedEvents;
$this->recordedEvents = [];
return $events;
}
}
reconstitute() で保存済みのデータから戻すときは、イベントを記録しません。イベントは「いま状態が変わった」ときにだけ生まれます。
UseCase は、保存の後でイベントを渡すだけ
interface DomainEventPublisher
{
/**
* 業務の更新と同じトランザクションの中で呼ぶ。
* ハンドラーに配るのは、トランザクションがコミットされたときだけ。
* 配れなかったイベントはログに残して捨てる。消えると困る依頼には使わない
*
* @param list<DomainEvent> $events
*/
public function publish(array $events): void;
}
final class ConfirmOrder
{
public function __construct(
private OrderRepository $orders,
private StockAllocator $stocks,
private TransactionManager $transaction,
private Clock $clock,
private ShipmentRequestQueue $shipmentRequests,
private DomainEventPublisher $events,
) {
}
public function execute(OrderId $id, int $operatorId): ConfirmOrderResult
{
return $this->transaction->run(function () use ($id, $operatorId) {
$order = $this->orders->findOrFail($id);
$order->confirm($operatorId, $this->clock->now());
$this->allocateStocks($order);
$this->orders->save($order);
$this->shipmentRequests->enqueue($id); // 消えると困る依頼は、これまでどおり明示的に
$this->events->publish($order->pullEvents());
return ConfirmOrderResult::from($order);
});
}
/** 集約の記事のループをメソッドに移しただけ。在庫の行をロックする順序をそろえるため、SKU の順に引き当てる */
private function allocateStocks(Order $order): void
{
$lines = $order->lines();
usort($lines, fn (OrderLine $a, OrderLine $b): int => strcmp($a->sku, $b->sku));
foreach ($lines as $line) {
if (! $this->stocks->allocate($line->sku, $line->quantity)) {
throw new OutOfStock($order->id(), $line->sku);
}
}
}
}
通知・集計・キャッシュの行は、UseCase から消えました。今後、確定に別の副作用が増えても、ConfirmOrder は変わりません。
save() が OrderWasModifiedConcurrently で失敗した場合は、publish() まで届かず、イベントは配られません。RetryOnConcurrentModification でやり直すと、注文を読み直して confirm() をもう一度呼ぶので、新しい Order が改めてイベントを記録します。失敗した回のイベントが残って、2回配られることはありません。
コミットの後に配り、配れなくても本体を失敗にしない
発行の実装は、Laravel の DB 接続の afterCommit() で、配るのをコミットの後に回します。トランザクションの中で呼ばれればコミットまで待ち(入れ子なら一番外側のコミットまで)、ロールバックされれば捨てられます。トランザクションの外で呼ばれれば、その場で配ります。
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\Connection;
use Psr\Log\LoggerInterface;
use Throwable;
final class AfterCommitDomainEventPublisher implements DomainEventPublisher
{
public function __construct(
private Connection $db, // TransactionManager と同じ接続。別の接続だと、業務の更新のコミットを待たない
private Dispatcher $dispatcher,
private LoggerInterface $logger,
) {
}
public function publish(array $events): void
{
foreach ($events as $event) {
$this->db->afterCommit(function () use ($event): void {
try {
$this->dispatcher->dispatch($event);
} catch (Throwable $e) {
// コミットは済んでいる。ここで例外を投げると、確定に成功したのに UseCase が失敗を返す
$this->logger->error('domain event was not delivered', [
'event' => $event::class,
'exception' => $e,
]);
}
});
}
}
}
例外を握りつぶしているように見えますが、理由が2つあります。1つは、コミットの後に投げた例外は transaction() から返り、利用者には「確定に失敗した」と見えることです。再送すると「確定済みの注文は確定できない」で失敗します。もう1つは、Laravel はコミット後のコールバックを順に実行するだけなので、1つが例外を投げると、それより後に予約したコールバック(ほかのイベントや、同じトランザクションのほかの処理)が動かなくなることです。
その代わり、ここを通るイベントは「配れなかったら消える」ものとして扱います。これが、出荷の依頼をイベントにしない理由でもあります。
もう1つ、ログにも残らずに消える場合があります。COMMIT の応答を受け取る前に接続が切れると、DB 側ではコミットされていても、transaction() は例外を投げ、予約したコールバックは捨てられます。利用者が再送すると「確定済みの注文は確定できない」になり、通知も、キャッシュの削除も、集計のやり直しも起きません。この記事のハンドラーは、それでも後から追いつけるものに限っています。キャッシュは期限が切れれば作り直され、集計は後で書く定期的な作り直しで直ります。メールは届かないまま残るので、届かないことに気づく必要があるなら、通知も Outbox で同じトランザクションに記録する側に移します。
ハンドラーごとに、同期か非同期かを決める
ハンドラーは Laravel のリスナーとして書きます。同期にするか、キューに回すかは、ハンドラーごとに決めます。
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Psr\Log\LoggerInterface;
use Throwable;
/** 同期:軽く、すぐ反映されてほしいもの */
final class ForgetOrderDetailCache
{
public function __construct(
private Cache $cache,
private LoggerInterface $logger,
) {
}
public function handle(OrderConfirmed $event): void
{
try {
$this->cache->forget("order-detail:{$event->orderId->value}");
} catch (Throwable $e) {
// ここで投げると、同じイベントの後ろのリスナー(メール・集計)がキューに積まれない。
// キャッシュは DB の写しなので、消せなかった値も期限が切れれば作り直される
$this->logger->warning('order detail cache was not forgotten', ['exception' => $e]);
}
}
}
/** 非同期:外部とやりとりするもの。リトライはキューに任せる */
final class SendOrderConfirmedMail implements ShouldQueue
{
public int $tries = 3;
public function __construct(private OrderMailer $mailer)
{
}
public function handle(OrderConfirmed $event): void
{
$this->mailer->sendConfirmed($event->orderId);
}
}
/** 非同期:集計。加算せず、確定した日の確定済みの注文から作り直して上書きする */
final class RebuildDailySales implements ShouldQueue
{
public int $tries = 10; // 同じ日の作り直しが動いている間は release() されるので、多めにする
public function __construct(private DailySalesProjector $sales)
{
}
/** @return list<object> */
public function middleware(OrderConfirmed $event): array
{
// 同じ日の作り直しを同時に動かさない。古い読み取りの結果で、新しい結果を上書きしないため
$day = $this->sales->businessDateOf($event->confirmedAt); // 業務のタイムゾーンでの日付(Y-m-d)
return [(new WithoutOverlapping("daily-sales:{$day}"))->releaseAfter(10)->expireAfter(180)];
}
public function handle(OrderConfirmed $event): void
{
$this->sales->rebuildFor($event->confirmedAt); // その日の確定済みの注文を集計し直して上書きする(実装は省略)
}
}
登録は、サービスプロバイダでまとめて書きます。
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
final class DomainEventServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(DomainEventPublisher::class, AfterCommitDomainEventPublisher::class);
}
public function boot(): void
{
Event::listen(OrderConfirmed::class, ForgetOrderDetailCache::class);
Event::listen(OrderConfirmed::class, SendOrderConfirmedMail::class);
Event::listen(OrderConfirmed::class, RebuildDailySales::class);
}
}
このサービスプロバイダは、Laravel 11 以降なら bootstrap/providers.php の配列に DomainEventServiceProvider::class を足して登録します(10 以前は config/app.php の providers)。登録を忘れると DomainEventPublisher が束縛されず、ConfirmOrder を解決するときに BindingResolutionException になります。
リスナーを app/Listeners に置いてイベントの自動検出に任せる場合は、この Event::listen() は書きません。両方で登録すると、同じリスナーが2回呼ばれます。私は、確定のときに何が起きるかを1か所で読めるように、明示的に登録するほうを選んでいます。
同期と非同期は、次の基準で分けています。
- 同期:プロセスの中で完結し、軽く、次のリクエストまでに反映されていてほしいもの。キャッシュの削除がその例です。同期のハンドラーはリクエストの応答を遅らせ、例外を投げると同じイベントのほかのハンドラーを止めるので、
Throwableを捕まえて、自分の失敗は自分で扱います - 非同期:外部とやりとりするもの(メール・外部 API)、重いもの(集計)、失敗したらやり直したいもの。キューに入るリスナーは、イベントのオブジェクトを丸ごとペイロードに入れるので、イベントには ID や日時のような値だけを持たせます
非同期のハンドラーは、キューのジョブと同じく「少なくとも1回」実行され、イベントが起きた順に動くとも限りません。集計を加算ではなく作り直しにしているのは、同じイベントで2回動いても、別の注文のイベントと順序が入れ替わっても、結果が同じになるからです。
ただし、作り直しが成り立つのは、同じ日の作り直しが同時に動かない場合だけです。例えば、注文 A と B が数秒差で確定し、A の作り直しが B のコミット前に集計を読み、B の作り直しが A と B を読んで書いた後に、A の作り直しが最後に書くと、B が抜けた集計が残ります。B のイベントはもう処理済みなので、後から直すきっかけもありません。そのため WithoutOverlapping で、同じ日の作り直しを1つずつ動かします。B のイベントは B のコミットの後に配られるので、B の作り直しは、それより前に始まった作り直しが終わるのを待ってから読むことになり、必ず B を含みます。
WithoutOverlapping のロックはキャッシュに置かれ、キャッシュの障害や期限切れで消えることがあります。私は、直近の数日分を夜間にもう一度作り直すコマンドを別に動かし、ずれが残っても翌日までに直るようにしています。確定が集中する日は、ロックを待って release() されるたびに試行回数を使うので、$tries を使い切って失敗する作り直しも出ます。それも、この夜間の作り直しで直ります。
集計し直す日は、イベントの confirmedAt、つまり確定した日です。既存の OrderStatus では、キャンセルできるのは未確定の注文だけなので、確定済みの注文が後から売上から抜けることはありません。返品のような「確定後の取り消し」を足すなら、取り消しのイベントにも元の確定日を持たせ、取り消した日ではなく確定した日を作り直します。
通知メールは、まれに2通届いても困らないものとして、重複を許容しています。キューが詰まって送るのが遅れても、確定済みの注文が別の状態に変わることは、いまの定義ではありません。確定後の取り消しを足すなら、ハンドラーで注文を読み直し、確定済みのときだけ送るようにします。
コミット後に消しても、キャッシュが古いまま残る場合は残ります。コミットの直前にキャッシュを外したリクエストが、まだ確定前の注文を読み、ForgetOrderDetailCache が動いた後でそれをキャッシュに書くと、確定前の内容が期限まで残ります。コミット後に消すことで、この窓は狭くなりますが、なくなりはしません。古い内容が残る時間の上限は、キャッシュの期限で決まります。それで困る画面なら、注文の version をキーに含める(order-detail:{id}:v{version})などして、古い内容を読む経路そのものをなくします。
なぜそう判断したか
UseCase から副作用を直接呼ばない理由
冒頭の書き方の問題は、副作用が本体のトランザクションの中にあること以上に、本体が副作用を知っていることです。メールや集計の都合で ConfirmOrder に手が入り、そのたびに確定の手順そのもののテストを見直すことになります。
UseCase の後で副作用を呼ぶ形(Controller で execute() の後にメールを送るなど)も考えられます。ただ、同じ確定をバッチや管理画面から呼ぶと、そこでも副作用を忘れずに書く必要があります。イベントにすれば、どの経路から確定しても同じハンドラーが動きます。
集約がイベントを記録する理由
UseCase の側で new OrderConfirmed(...) を作る形もあります。ただ、確定日時や確定者のように、イベントの中身は状態の変更と同じ値です。状態を変えたその場で記録すれば、イベントと状態がずれることはありません。「確定した」という事実が生まれる場所は confirm() なので、記録する責任もそこに置きます。集約の境界を「一緒に正しくなければならないルール」で決めたのと同じく、「誰がこの事実を知っているか」で置き場所を決めています。
一方で、配る(publish() を呼ぶ)のは UseCase にしています。Repository の save() の中で配る形にすれば呼び忘れはなくなりますが、保存とイベントの配信という2つの理由で Repository に手が入ることになります。UseCase の1行なら、読めば分かり、テストでも確かめられます。
Eloquent のモデルイベントを使わない理由
Eloquent のオブザーバーで updated を受け取り、status が変わったかを見て通知する方法もあります。ただ、これは「どの列が変わったか」から業務の意味を推測しているだけです。pending から confirmed に変わったのか、管理者がデータを補正したのかは、列の差分からは分かりません。Query Builder での一括更新では発火もしません。業務の事実は、業務の言葉で、業務のルールを守った場所から出します。
出荷の依頼をイベントにしない理由
出荷の依頼もイベントのハンドラーにすれば、ConfirmOrder はさらに短くなります。ただ、出荷の依頼は「注文を確定する」という操作の約束の一部です。それが UseCase から見えなくなると、確定のコードを読んだ人は、出荷が依頼されることを知りません。そのうえ、上の発行の実装では、配れなかったイベントは消えます。
イベントのハンドラーに任せてよいのは、本体が「誰が受け取るか」を知らなくてよく、届かなくても本体の約束が破れないものだけです。消えると困るものは、UseCase から明示的に依頼し、Outbox で同じトランザクションに記録します。
どこから設計過剰になるか
- 副作用が1つしかなく、増える見込みもない:イベントとハンドラーと登録の3か所に分けるより、UseCase から Interface で依頼するほうが読みやすいです。イベントは、同じ事実に複数の副作用がぶら下がり始めてから入れます
- すべての状態変更にイベントを用意する:「将来使うかもしれない」イベントは、受け取る人がいないまま、名前と中身だけが保守の対象になります。受け取るハンドラーができたときに足します
- すべてのイベントを Outbox に記録する:イベントの量だけテーブルが増え、配送の仕組みも要ります。消えると困るものだけを Outbox にし、それ以外はコミット後に配って、配れなければログに残すので足ります
- イベントで状態を保存する(イベントソーシング):副作用を切り離すことと、イベントを正の記録にすることは別の設計です。ここで扱ったのは前者だけです
- ほかのサービスにイベントを公開する:別のサービスが購読するイベントは、そのまま公開の契約になり、項目を変えるには互換性を保つ段階が要ります。内部のドメインイベントをそのまま外に出さず、外向けの形を別に決めるべきだと考えていますが、イベント駆動の設計は私自身まだ深めている途中の領域です
まとめ
- 本体と同時に正しくなくてよい副作用は、集約が記録したドメインイベントを、コミットの後にハンドラーへ配って切り離す
- ハンドラーは、軽くすぐ反映したいものを同期、外部とのやりとりや重い処理を非同期にし、非同期のものは重複と順序の入れ替わりに耐える形にする
- 本体の約束の一部で、消えると困るもの(出荷の依頼など)はイベントにせず、UseCase から明示的に依頼して Outbox に記録する