第20章:ドメインイベント②:どこで発行する?(Raiseポイント)📍🔔
今日のゴール🎯✨
- 「ドメインイベントを**どこでRaise(発行)**するのが正解?」が腹落ちする😊
- 「一貫性(整合性)を壊さない」Raise位置の考え方がわかる🔒
- C#で “集約でRaise → コミット後に外で配る” の基本形を手を動かして作れる⌨️💪
(この章の考え方は、MicrosoftのDDD/ドメインイベント解説とも方向性が一致してるよ📚✨) (Microsoft Learn)
まず結論🧠✨(Raiseポイントはここ!)
✅ 基本ルール(超だいじ)📌
「その事実が“成立した瞬間”にRaiseする」 そして、その“成立”を決めるのはたいてい 集約(Aggregate) だよ🔔🧱
- 状態が変わる(=事実が成立する) → そのメソッドの中(=集約内)でRaiseするのが自然✨
- でも… 送信(メール・通知・外部連携)までは集約でやらない 🙅♀️ → “副作用”は外に逃がす(次章21でガッツリやるよ📦📩)
まずダメ例😇💥 → どう壊れる?
ダメ例①:ControllerでRaiseしちゃう🎮💦
「注文が支払い済みになった」って、Controllerが決めていい? …ううん、ドメインのルールを知らない層が “事実認定” すると事故る😵💫
- まだ検証前なのにイベント出しちゃう
- 例外でロールバックしたのにイベントだけ飛ぶ
- 結果、「起きてないのに起きたことになってる」世界線👻
ダメ例②:集約の中でメール送信📧🔥
集約の Pay() の中で EmailSender.Send() とかやると…
- テストが地獄(メール送らないと通らない)😇
- 失敗時の再実行で二重送信しやすい🔁💥
- “ドメイン”が“インフラ”に汚染される🧼💦
Raise位置を決める3つの判断軸🧭✨
① それは「集約の状態変化」?🧱
例:Orderが Paid になった、Shipped になった
➡️ 集約内でRaise が基本✨
② それは「複数集約の調停」?🤝
例:注文確定後に在庫引当も必要、ポイント付与も必要…みたいな “連携の都合” ➡️ アプリ層(ユースケース層)で調停 して、必要ならイベントを作る(ただし“事実”の源泉は集約)📦✨ (Microsoftも、複数集約にまたがるルールは最終的整合性+ドメインイベントで扱う話をしてるよ) (Microsoft Learn)
③ それは「技術都合」?🛠️
例:EF Coreのフックで勝手にRaise、みたいな ➡️ 初学者はまず やらない 方が安全🙅♀️(便利だけど、見えにくくなる😵)
良い例😎✨:集約でRaiseして「ためる」→ アプリ層で「配る」

ざっくり構図(これが気持ちいいやつ)🧠🧩
- 集約:状態を変える + イベントを
DomainEventsに積む🧺 - アプリ層:保存(コミット)成功後に、積まれたイベントを配る📣
- ハンドラ:メール・通知・ログ・他モジュール連携などの副作用を担当📦
「Raise(事実の発生)」と「Publish(配布)」を分ける これで一貫性が守りやすくなるよ🔒✨
手を動かす(C#)⌨️✨:最小の“Raise→配布”セット
1) Domain:イベントの型と、集約の土台🧱🔔
namespace Ordering.Domain;
// 目印(マーカー)としてのインターフェース
public interface IDomainEvent
{
DateTimeOffset OccurredAt { get; }
}
// 集約がイベントをため込むための土台
public abstract class AggregateRoot
{
private readonly List<IDomainEvent> _domainEvents = new();
public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents;
protected void AddDomainEvent(IDomainEvent e) => _domainEvents.Add(e);
public void ClearDomainEvents() => _domainEvents.Clear();
}
2) Domain:Order集約で「事実成立の瞬間」にRaiseする🛒✅
namespace Ordering.Domain;
public enum OrderStatus { Created, Paid, Shipped, Cancelled }
public sealed record OrderId(Guid Value);
public sealed record PaymentId(Guid Value);
// 「支払い完了」という “起きた事実” ✅(過去形)🔔
public sealed record OrderPaid(OrderId OrderId, PaymentId PaymentId, DateTimeOffset OccurredAt) : IDomainEvent;
public sealed class Order : AggregateRoot
{
public OrderId Id { get; }
public OrderStatus Status { get; private set; } = OrderStatus.Created;
public PaymentId? PaidBy { get; private set; }
public DateTimeOffset? PaidAt { get; private set; }
public Order(OrderId id) => Id = id;
public void Pay(PaymentId paymentId, DateTimeOffset now)
{
// ここが「一貫性を守る」場所🔒
if (Status != OrderStatus.Created)
throw new InvalidOperationException("この注文は支払いできません");
Status = OrderStatus.Paid;
PaidBy = paymentId;
PaidAt = now;
// ✅ 事実が成立した瞬間にRaise(ここがRaiseポイント)🔔
AddDomainEvent(new OrderPaid(Id, paymentId, now));
}
}
3) Application:保存成功後にイベントを配る📦📣
namespace Ordering.Application;
using Ordering.Domain;
public interface IOrderRepository
{
Task<Order?> FindAsync(OrderId id, CancellationToken ct);
Task SaveAsync(Order order, CancellationToken ct);
}
// 「配る」係(中身は後で差し替えOK)🧩
public interface IDomainEventDispatcher
{
Task DispatchAsync(IEnumerable<IDomainEvent> events, CancellationToken ct);
}
public sealed class PayOrderUseCase
{
private readonly IOrderRepository _repo;
private readonly IDomainEventDispatcher _dispatcher;
public PayOrderUseCase(IOrderRepository repo, IDomainEventDispatcher dispatcher)
{
_repo = repo;
_dispatcher = dispatcher;
}
public async Task ExecuteAsync(Guid orderId, Guid paymentId, CancellationToken ct)
{
var order = await _repo.FindAsync(new OrderId(orderId), ct)
?? throw new InvalidOperationException("注文が見つかりません");
order.Pay(new PaymentId(paymentId), DateTimeOffset.UtcNow);
// ✅ まず保存(コミット)
await _repo.SaveAsync(order, ct);
// ✅ 保存成功後に配る(Publishは外!)
await _dispatcher.DispatchAsync(order.DomainEvents, ct);
order.ClearDomainEvents();
}
}
この形にしておくと、あとで Outbox(第22〜24章) に繋げるのが超ラクになるよ🔁📤✨ (「DB更新とイベント送信がズレる」問題に自然に対処できるルート!)
ちょい上級🍰:EF Coreで“コミット後に配る”を自動化する話(軽く)
「SaveChanges のタイミングで配りたい」ってなったら、EF Coreには SaveChangesInterceptor があるよ🧩
公式にも SaveChangesInterceptor が用意されてる(EF Core 10でもOK) (Microsoft Learn)
EF Core 10は .NET 10 前提のLTSで、2028年11月までサポート予定だよ📅✨ (Microsoft Learn)
ただし!最初は “見える形(手でDispatch)” の方が理解が早い😊✨ Interceptorは慣れてからでOKだよ〜👍
Raiseポイントの早見表🧾✨
-
集約内でRaiseする(おすすめ) 😎
- ✅ “OrderがPaidになった” みたいな 状態変化の事実
- ✅ 不変条件(ルール)を通過したあとに出せる
-
アプリ層でRaiseする(条件付き) 🤔
- ✅ “複数集約の調停が完了した” みたいな ユースケースの節目
- ✅ ただし、元になる事実は各集約でRaiseしておくのが基本
-
UI/Controller/InfrastructureでRaiseする(避けたい) 🙅♀️
- ❌ 事実認定がズレる
- ❌ ロールバックとイベントがズレやすい
ミニ演習📝✨(手を動かすと定着するよ〜!)
-
OrderShippedイベントを追加してみよう🚚🔔Ship()メソッドでStatus = ShippedにしてからRaiseできてる?
-
「支払い済みの注文だけ発送できる」ガード条件を入れよう🚧
-
“ポイント付与”はどこでやる?🎁
- ヒント:Orderの中でポイントを増やす? → たぶん違う😆
- 「OrderPaidをハンドラで受けて、別の処理を起動」が気持ちいい✨
AI活用プロンプト例🤖✨(Copilot / Codex向け)
- 「
AggregateRootに DomainEvents を持たせて、Order.Pay()がOrderPaidをRaiseする形にリファクタして」🧹✨ - 「
PayOrderUseCaseのテストを書いて。イベントが1回だけ発行されることも検証して」🧪🔁 - 「“Raiseは集約、Publishは外” を守れていない箇所がないかレビューして、修正案も出して」🔍🧠
まとめ(覚える1行)📌✨
「事実が成立した瞬間に集約でRaise。配布(副作用)はコミット後に外でやる」 🔔🧱➡️📦
必要なら次の第21章で、“副作用を外に逃がすハンドラ設計” を気持ちよく作っていこうね😆📩✨ (メール送信・通知・ログが“後付けで増やせる快感”くるよ〜!)