第21章:ドメインイベント③:ハンドラ設計(副作用は外へ)📦📩
ミニECの「注文が支払われた(OrderPaid)」を例に、“起きた事実”に反応して動く仕組みを作るよ〜!😊 ここから一気に「機能追加がラクになる」感覚が出てくる章です🎉
今日のゴール🎯✨
- ドメイン(集約)から 副作用(メール送信・通知・ログ・外部API)を追い出せるようになる📤
- ドメインイベントの **ハンドラ設計の型(置き場所・責務・DI)**を覚える🧩
- 「同じイベントに、複数の反応を足せる」状態にする(疎結合の快感😆)🌈
まずダメ例😇 → 良い例😎
ダメ例😇:集約の中で副作用やりまくり💥
「支払い完了したらメール送って、ポイント付与して、Slack通知して…」を Orderの中で直にやるやつ。
- テストが地獄😵(メール送信のモック、外部API、ログ…)
- 失敗時の扱いがぐちゃぐちゃ(DB更新は成功したのにメール失敗…みたいなズレ)📉
- 機能追加のたびに Order が肥大化🍔
良い例😎:集約は“事実を宣言”だけ🔔✨
集約はこう言うだけ:
「支払いが完了した(OrderPaid)」🔔
実際にメールを送るのは、外の世界(ハンドラ)に任せる📩➡️
ハンドラ設計の基本ルール(超大事)🧠🧩

ルール1️⃣:ドメインは“副作用ゼロ”が目標🧼✨
- ドメイン(Domain)は「正しい状態遷移」「不変条件」だけ守る💎
- メール・通知・外部API・ログは アプリ層/インフラ層へ📦
ルール2️⃣:ハンドラは「反応して外へ投げる係」📩➡️
ハンドラの仕事はだいたいこの3つ:
- 通知する(メール、Push、Slack)📣
- 記録する(監査ログ、分析イベント)📝
- “次の仕事”を作る(別処理のキック)🚀
ルール3️⃣:ハンドラから集約を直接いじらない🙅♀️
イベントを受けて、また同じ集約を更新すると…
- 無限ループの香り♾️😇
- “いつどの順で”問題が爆発💥
更新が必要なら、**別のCommand(ユースケース)**として起こす、が安全✍️
ルール4️⃣:同じイベントにハンドラは複数OK🎉
「OrderPaid」に対して
- レシートメール送る📩
- 会計ログを書く🧾
- ポイント付与の処理をキック🎁 みたいに 追加するだけで拡張できる✨
ルール5️⃣:イベントの配送は“遅延配送”が基本⏳
ドメインイベントは、即時にハンドラを呼ばず、いったん溜めておいて **トランザクション確定の前後(SaveChangesの前後)**でまとめて配るのが定番だよ🧠✨ (Microsoft Learn)
(ここは次の Outbox 章にも繋がるやつ!📤💥)
置き場所のおすすめ(モジュラーモノリス向け)🏠🧩
Orderingモジュールの例:
-
Modules/Ordering/Domain/Events/- ドメインイベント(“起きた事実”)だけ置く🔔
-
Modules/Ordering/Application/EventHandlers/- ドメインイベントハンドラ(ユースケース寄り)📦
-
Modules/Ordering/Infrastructure/...- メール送信などの実装(外部I/O)📩🌐
手を動かす(C#)⌨️✨
(例:OrderPaid を受けて「レシートメール送信」と「監査ログ」を追加するよ😊)
ちなみに今どきの前提としては **.NET 10(LTS)**が最新で、2025-11-11リリース&2028-11-14までサポートの見込みだよ📅✨ (Microsoft) 言語は C# 14 が最新で .NET 10 でサポートされてるよ🧠✨ (Microsoft Learn)
1) ドメインイベントを用意する🔔
namespace Modules.Ordering.Domain.Events;
public interface IDomainEvent
{
DateTime OccurredAtUtc { get; }
}
public sealed record OrderPaidDomainEvent(
Guid OrderId,
Guid CustomerId,
decimal Amount
) : IDomainEvent
{
public DateTime OccurredAtUtc { get; } = DateTime.UtcNow;
}
2) 集約でイベントを“溜める”🧺✨
namespace Modules.Ordering.Domain;
using Modules.Ordering.Domain.Events;
public abstract class AggregateRoot
{
private readonly List<IDomainEvent> _domainEvents = new();
public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents;
protected void Raise(IDomainEvent ev) => _domainEvents.Add(ev);
public void ClearDomainEvents() => _domainEvents.Clear();
}
public sealed class Order : AggregateRoot
{
public Guid Id { get; private set; }
public Guid CustomerId { get; private set; }
public decimal TotalAmount { get; private set; }
public bool IsPaid { get; private set; }
public void MarkAsPaid()
{
if (IsPaid) return; // ざっくりガード(詳細は状態機械章で✨)
IsPaid = true;
Raise(new OrderPaidDomainEvent(Id, CustomerId, TotalAmount));
}
}
3) ハンドラの共通インターフェースを作る🧩
MediatR みたいなライブラリを使う方法もあるけど、まずは 最小の自前でいくね😊
namespace Modules.SharedKernel.Events;
public interface IDomainEventHandler<in TEvent>
{
Task Handle(TEvent ev, CancellationToken ct);
}
4) 副作用サービスはインターフェースで抽象化🔌✨
namespace Modules.Ordering.Application.Abstractions;
public interface IEmailSender
{
Task SendAsync(string to, string subject, string body, CancellationToken ct);
}
public interface IAuditLogger
{
Task WriteAsync(string message, CancellationToken ct);
}
5) イベントハンドラを書く(副作用はここ)📩📝
✅ レシートメール送信ハンドラ📩
namespace Modules.Ordering.Application.EventHandlers;
using Modules.Ordering.Application.Abstractions;
using Modules.Ordering.Domain.Events;
using Modules.SharedKernel.Events;
public sealed class SendReceiptEmailOnOrderPaidHandler
: IDomainEventHandler<OrderPaidDomainEvent>
{
private readonly IEmailSender _email;
public SendReceiptEmailOnOrderPaidHandler(IEmailSender email)
=> _email = email;
public Task Handle(OrderPaidDomainEvent ev, CancellationToken ct)
{
var subject = $"お支払いありがとうございます🧾✨ Order: {ev.OrderId}";
var body = $"合計: {ev.Amount} 円\nいつもありがとう😊";
// ここでは例として to を固定にしないで、CustomerId→メール解決を別サービスにしてもOK👍
return _email.SendAsync("customer@example.com", subject, body, ct);
}
}
✅ 監査ログハンドラ📝
namespace Modules.Ordering.Application.EventHandlers;
using Modules.Ordering.Application.Abstractions;
using Modules.Ordering.Domain.Events;
using Modules.SharedKernel.Events;
public sealed class AuditOnOrderPaidHandler
: IDomainEventHandler<OrderPaidDomainEvent>
{
private readonly IAuditLogger _audit;
public AuditOnOrderPaidHandler(IAuditLogger audit)
=> _audit = audit;
public Task Handle(OrderPaidDomainEvent ev, CancellationToken ct)
=> _audit.WriteAsync($"OrderPaid: order={ev.OrderId} amount={ev.Amount}", ct);
}
👆見て!同じイベントに ハンドラ2つ付けられた🎉 これが「機能追加が怖くなくなる」第一歩だよ〜😆✨
6) “配送係(ディスパッチャ)”を用意する📦🚚
溜まっている DomainEvents を集めて、該当ハンドラ全部に配る係!
namespace Modules.SharedKernel.Events;
using Microsoft.Extensions.DependencyInjection;
public interface IDomainEventDispatcher
{
Task DispatchAsync(IEnumerable<object> domainEvents, CancellationToken ct);
}
public sealed class DomainEventDispatcher : IDomainEventDispatcher
{
private readonly IServiceProvider _sp;
public DomainEventDispatcher(IServiceProvider sp) => _sp = sp;
public async Task DispatchAsync(IEnumerable<object> domainEvents, CancellationToken ct)
{
foreach (var ev in domainEvents)
{
// ev の型に対する IDomainEventHandler<T> を全部取り出して順番に実行
var handlerType = typeof(IDomainEventHandler<>).MakeGenericType(ev.GetType());
var handlers = _sp.GetServices(handlerType);
foreach (var handler in handlers)
{
var method = handlerType.GetMethod("Handle")!;
var task = (Task)method.Invoke(handler, new[] { ev, ct })!;
await task.ConfigureAwait(false);
}
}
}
}
現実のプロジェクトでは MediatR を使って同じことをもっと綺麗にすることも多いよ😊 でも今は「仕組みが腹落ち」するのが最優先👍
7) いつDispatchするの?⏳(超重要ポイント)
おすすめは、SaveChangesの前後で「まとめてDispatch」だよ✨ (Microsoft Learn) EF Coreなら SaveChangesInterceptor を使うと、DbContextを汚さずに差し込めるよ〜🧰 (Microsoft Learn)
(この“ズレ問題”が次の Outbox 章の主役になる📤💥)
ミニ演習📝✨
-
OrderPaidDomainEventに対して「ポイント付与」を追加してみよう🎁ILoyaltyPointService.AddPoints(customerId, points)みたいなインターフェースを作って- ハンドラ1個追加するだけでOKにする😊
-
ハンドラが失敗したとき(メール送信失敗など)に
- 例外にする?ログだけ?リトライ? を「エラー設計(第17-18章)」の分類で考えてみよう🚧✨
よくある落とし穴⚠️😵
- ハンドラの中でDB更新を始めて、地獄の依存ループになる♻️💥 → 更新したいなら “別のユースケース” に切る✍️
- ハンドラの順序に依存しちゃう(先にログ、次にメール…みたいな前提) → 依存しない設計に寄せる(順序は保証しない方が安全)🧠
- イベントに情報を詰め込みすぎる(巨大DTO化)🍔 → まずは “事実” に必要最小限だけ載せる🔔
AI活用プロンプト例🤖✨
- 「
OrderPaidDomainEventを受けて、メール送信するIDomainEventHandler<OrderPaidDomainEvent>をC#で書いて。副作用はIEmailSender経由にして、テストしやすくして」📩🧪 - 「同じイベントに監査ログも追加したい。
IAuditLoggerを使うハンドラを追加して。依存関係が増えないように」📝🧩 - 「Domainに副作用が入ってるかチェックする観点をリスト化して」🔍✅
まとめ(覚える1行)📌✨
「ドメインは“事実を宣言”だけ。副作用はハンドラに逃がす!」 🔔➡️📩🧩
次の第22章は、この章でチラ見せした「DB更新とイベント送信がズレる問題」📤💥を、Outboxで倒しに行くよ〜!😆🔥