本章の方針
Domain Events(ドメインイベント)は、集約間の結合を疎にする 強力な武器だ。同時に、運用観測性(observability)が追いつかないと、すぐに「何が起きているか分からないシステム」 を生む諸刃の剣でもある。本章は、Domain Events の3つの配信モード、実装パターン、そして陥りがちな落とし穴を扱う。
Domain Events とは何か
Domain Event の定義は単純だ。
ドメインイベント = ドメイン内で「起きた事実」を表す不変オブジェクト
重要なのは 「起きた事実」 という点だ。upgradeSubscription() のような命令(コマンド)ではなく、SubscriptionUpgraded(契約がアップグレードされた)のような 完了した事実 を表す。
SaaS契約管理での例を見る。
// [Domain Event] SubscriptionUpgraded
class SubscriptionUpgraded {
readonly type = 'SubscriptionUpgraded' as const
readonly occurredAt: Date
constructor(
readonly subscriptionId: SubscriptionId,
readonly customerId: CustomerId,
readonly previousPlanId: PlanId,
readonly newPlanId: PlanId,
readonly effectiveFrom: Date,
) {
this.occurredAt = new Date()
}
}
イベントは 過去形で命名 する。UpgradeSubscription ではなく SubscriptionUpgraded。「アップグレードせよ」ではなく「アップグレードされた」だ。
集約がイベントを発火する
イベントは集約(Entity)から発火される。
// [集約] Subscription が イベントを蓄積
class Subscription {
private _events: DomainEvent[] = []
upgrade(newPlan: Plan): void {
if (this._status !== 'active') throw new Error('Inactive')
const previousPlanId = this._planId
this._planId = newPlan.id
// ← イベントを発火
this._events.push(new SubscriptionUpgraded(
this.id,
this.customerId,
previousPlanId,
newPlan.id,
new Date(),
))
}
// 発火したイベントを取り出す(Application Serviceが取り出して配信する)
pullEvents(): DomainEvent[] {
const events = [...this._events]
this._events = []
return events
}
}
3つの配信モード
Domain Events の配信方法は3種類ある。プロジェクトの要件で選ぶ。
モード1:同期(In-Process Event Bus)
同一プロセス内で、保存後に同期的にハンドラを呼ぶ。
class UpgradeSubscriptionHandler {
async handle(command: UpgradeSubscriptionCommand): Promise<void> {
await this.unitOfWork.transaction(async () => {
const subscription = await this.repo.findById(command.subscriptionId)
subscription.upgrade(newPlan)
await this.repo.save(subscription)
// ← 同一トランザクション内で同期配信
for (const event of subscription.pullEvents()) {
await this.eventBus.dispatchSync(event)
}
})
}
}
メリット:実装が単純、即座に結果が伝搬、強整合性
デメリット:ハンドラが失敗するとコマンドが失敗、疎結合性が限定的、外部サービス連携に弱い
モード2:Outbox パターン(最も推奨される)
イベントを DBのouboxテーブルに保存 し、別のワーカーが非同期に配信する。
class UpgradeSubscriptionHandler {
async handle(command: UpgradeSubscriptionCommand): Promise<void> {
await this.unitOfWork.transaction(async () => {
const subscription = await this.repo.findById(command.subscriptionId)
subscription.upgrade(newPlan)
await this.repo.save(subscription)
// ← 同じトランザクションで outbox に書く
for (const event of subscription.pullEvents()) {
await this.outbox.append(event)
}
})
// トランザクション完了後、別ワーカーが outbox → Kafka/SQS に配信
}
}
sequenceDiagram
participant C as Client
participant AS as Application Service
participant DB as DB (Subscription + outbox)
participant W as Outbox Worker
participant MQ as Message Broker
participant H as Event Handler
C->>AS: upgrade command
AS->>DB: BEGIN TX
AS->>DB: UPDATE subscriptions
AS->>DB: INSERT INTO outbox
AS->>DB: COMMIT
AS->>C: OK
loop 非同期配信
W->>DB: SELECT FROM outbox WHERE published = false
W->>MQ: publish event
MQ->>H: deliver
W->>DB: UPDATE outbox SET published = true
end
メリット:強整合性(DBコミットとイベント登録が同一トランザクション)
配信は非同期で疎結合
At-least-once 配信が保証される
デメリット:ouboxテーブルの運用、冪等性設計、配信遅延の受容が必要
2026年時点、分散環境でのDomain Events配信は Outbox が事実上の標準 だ。
モード3:イベントソーシング(Event Sourcing)
イベント自体を永続化の真実とする。集約の状態はイベントの再生で復元する。
// Subscriptionの状態はイベント列で表現
const events = [
new SubscriptionStartedTrial(...),
new SubscriptionActivated(...),
new SubscriptionUpgraded(...),
]
// 現在状態はイベントを畳み込んで復元
const subscription = events.reduce(
(state, event) => state.apply(event),
Subscription.empty(),
)
メリット:完全な監査ログ、過去状態の再現、DCBと相性抜群
デメリット:学習コスト高、スキーマ変更(イベントバージョニング)が複雑、
全クエリに読み取りモデル投影が必要
Event Sourcing は強力だが、プロジェクト全体を飲み込むスタイルの変化 だ。「このシステムはEvent Sourcingで構築する」という初期判断が必要になる。後から部分的に導入するのは難しい。
Outbox パターンの実装(2026年版)
Outbox の具体実装を示す。
// outboxテーブルのスキーマ
/*
CREATE TABLE outbox (
id UUID PRIMARY KEY,
aggregate_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL,
published_at TIMESTAMPTZ,
attempts INT DEFAULT 0
);
CREATE INDEX ON outbox (published_at) WHERE published_at IS NULL;
*/
class OutboxWorker {
constructor(
private readonly db: DatabaseClient,
private readonly broker: MessageBroker,
) {}
async run(): Promise<void> {
while (true) {
const batch = await this.db.query(
`SELECT * FROM outbox
WHERE published_at IS NULL
ORDER BY occurred_at
LIMIT 100
FOR UPDATE SKIP LOCKED`,
)
for (const row of batch) {
try {
await this.broker.publish(row.event_type, row.payload)
await this.db.query(
`UPDATE outbox SET published_at = NOW() WHERE id = $1`,
[row.id],
)
} catch (e) {
await this.db.query(
`UPDATE outbox SET attempts = attempts + 1 WHERE id = $1`,
[row.id],
)
}
}
await sleep(1000)
}
}
}
実装上のポイントは4つ。
FOR UPDATE SKIP LOCKED:複数ワーカーの並行動作を許容published_at IS NULLにインデックス:未配信イベントの高速検索- At-least-once を前提に設計:イベント消費側は冪等にする
- 再試行時の指数バックオフ:失敗イベントが詰まらないように
2026年時点では Debezium のようなCDC(Change Data Capture)ツールを使って、outbox テーブルから Kafka への配信を自動化 するのが一般的だ。
イベント消費側の冪等性
Domain Events を At-least-once で配信する場合、消費側は必ず冪等(idempotent) でなければならない。同じイベントを2回受け取っても、結果は1回目と同じでなければならない。
// [Handler] 請求書発行
class IssueInvoiceHandler {
constructor(
private readonly invoiceRepo: InvoiceRepository,
private readonly processedEvents: ProcessedEventStore,
) {}
async handle(event: SubscriptionUpgraded): Promise<void> {
// 冪等性チェック:同じイベントIDを処理済みなら無視
const processed = await this.processedEvents.has(event.eventId)
if (processed) return
// 業務処理
const invoice = Invoice.createForUpgrade(
event.customerId,
event.previousPlanId,
event.newPlanId,
event.effectiveFrom,
)
await this.invoiceRepo.save(invoice)
// 処理済みマーク
await this.processedEvents.mark(event.eventId)
}
}
冪等性の設計を忘れると、Domain Eventsの導入は逆に障害を増やす。同じイベントの重複配信で、請求書が二重発行される、メールが二回飛ぶ、といった事故が起きる。
Domain Events の肯定的な体験
体験1:通知チームとの分離が楽になった
かつては「契約がアップグレードされたらメールを送る」ロジックが、契約管理コンテキストのコードに埋め込まれていた。SubscriptionUpgraded イベントを発火して通知コンテキストが受信する形に変えた結果、通知チームが独立してデプロイできる ようになった。
体験2:監査ログが副作用として得られた
Outbox テーブルは 事実上の監査ログ として機能する。「いつ、誰が、何を変更したか」の問い合わせに対して、outbox テーブルを参照するだけで答えられる。別途監査ログを書く必要がなくなった のは大きな副産物だった。
体験3:機能追加の速度が上がった
「契約アップグレード時にSlack通知を追加したい」という要望に対し、既存コードを一切触らず、新しいハンドラを追加するだけで済む ようになった。オープン・クローズド原則がイベント駆動で自然に実現した。
Domain Events の否定的な体験
体験1:「何がいつ起きているか」が追えなくなった
イベントハンドラが10個を超えると、1つの契約アップグレードで何が起きるかが追えない 状態になった。新しく参画したメンバーが「アップグレード処理を読もう」としたとき、ファイル横断でハンドラを探し回ることになる。
対策:ドメインイベントごとに「誰が購読しているか」を README.md やドメインイベントカタログ に明記する運用を導入した。
体験2:デバッグが劇的に難しくなった
「なぜこの請求書が生成されたのか」を追うとき、同期的コードなら1ファイル読めば分かる。イベント駆動では、outbox → Kafka → Handler → Invoice という3ホップ を横断する必要がある。Distributed Tracing(OpenTelemetry)の導入 が事実上必須になった。
体験3:イベントのバージョニング
最初は SubscriptionUpgraded に newPlanId しか持たせていなかった。半年後、effectiveFrom を追加する必要が出た。既存の consumer は古いスキーマを期待している。イベントスキーマの進化 は Event Sourcing でなくとも発生し、設計時に見落としていた領域だった。
イベントのバージョニングの3つのアプローチ:
1. 後方互換フィールド追加のみ許可(破壊的変更は新イベント型)
2. スキーマレジストリ(Avro/Protobuf)で管理
3. Upcaster(古いイベントを新形式に変換する関数)を用意する
アンチパターン:イベント駆動の失敗
Domain Events で陥りがちな失敗を整理する(Ch.8でも扱う)。
アンチパターン1:Event-Carried State Transfer の乱用
// ❌ イベントに集約の全状態を詰め込む
class SubscriptionUpgraded {
constructor(
readonly subscription: SubscriptionSnapshot, // ← 契約の全フィールド
readonly customer: CustomerSnapshot, // ← 顧客の全フィールド
readonly previousPlan: PlanSnapshot,
readonly newPlan: PlanSnapshot,
) {}
}
これは Event-Carried State Transfer(Martin Fowlerが命名)と呼ばれるパターンで、意図的に使う場合もあるが、安易に採用すると集約間の境界が崩壊 する。受信側が「相手側の集約の全てを知っている」状態になる。
原則:イベントは「何が起きたか」の 最小限の情報 を持つ。必要に応じて受信側が他の集約から情報を取得する。
アンチパターン2:イベント連鎖の暴走
「イベントAが来たらイベントBを発火」「イベントBが来たらイベントCを発火」…のような連鎖は、無限ループや予期せぬ副作用の温床 になる。
対策:イベント連鎖は2段までに制限する、ハンドラで新イベントを発火する前に「本当に必要か」を検討する。
アンチパターン3:ドメインイベントと通信イベントの混同
ドメインイベント:ドメイン内で「起きた事実」(例:SubscriptionUpgraded)
通信イベント:システム間の通信用メッセージ(例:SendEmailRequest)
両者は用途が異なる。通信イベントをドメインイベントと同じチャネルに流すと、「ドメインの事実」と「システム間の依頼」が混ざる。別チャネルで扱う。
本章のまとめ
・Domain Events = ドメインで「起きた事実」を表す不変オブジェクト(過去形で命名)
・配信モードは3種類:同期/Outbox/Event Sourcing
・2026年の標準は Outbox パターン
・消費側の冪等性は必須
・観測性(Distributed Tracing・イベントカタログ)が追いついて初めて機能する
・イベントは最小限の情報を持つ。状態丸ごと渡さない
次章では、ここまで扱ってきた全パターンの失敗を アンチパターン・カタログ として症状・根本原因・脱出法の形式で整理する。