目次を表示する

EvansとVernonで学ぶDDD

[Vernon追加] ドメインイベント(Domain Events)── 青本にはなかったもの

ドメインイベントとは何か

Evansの「Domain-Driven Design」(2003年)にはドメインイベントという概念が体系的に定義されていない。エンティティや値オブジェクト、集約、リポジトリといった戦術的設計パターンは青本の中心をなすが、「ドメイン内で何かが起きたという事実をどう表現するか」という問いに対して、青本は明確な答えを示していない。

この空白を埋めたのがVaughn Vernonである。Vernonは「Implementing Domain-Driven Design」(2013年)の中でドメインイベントを独立した概念として体系化し、実装パターンまで掘り下げた。

ドメインイベントとは、ドメイン内で発生した「過去の出来事」を表すオブジェクトである。3つの特徴がある。

  1. 過去の事実を表す: 「これから起こること」ではなく「すでに起きたこと」を記録する
  2. 不変(イミュータブル): 過去の事実は変更できない。インスタンス生成後に内容が書き換わることはない
  3. 過去形で命名する: CandidateApplied(候補者が応募した)、OfferExtended(内定が出た)のように、英語の過去形で命名する

命名の慣例は設計の意図を反映している。現在形の ExtendOffer はコマンド(これから実行する操作)の名前であり、過去形の OfferExtended はその結果として発生したイベントの名前である。両者を区別することで、「何を命令するか」と「何が起きたか」が明確に分かれる。


なぜドメインイベントが必要か

コンテキスト間の疎結合な通信

複数のコンテキストが連携するとき、単純な実装としては直接メソッド呼び出しが考えられる。

採用管理システムで内定(Offer)が発行された場合、選考管理コンテキストが通知コンテキストのサービスを直接呼び出す実装は次のようになる。

// 直接呼び出し(同期)の例 ── これに問題がある
class ScreeningApplicationService {
  extendOffer(processId: string, salary: number): void {
    const process = this.repository.findById(processId)
    process.extendOffer(new Money(salary))
    this.repository.save(process)

    // 選考管理コンテキストが通知コンテキストを直接呼ぶ
    this.notificationService.sendOfferEmail(processId)
  }
}

この実装の問題は2つある。第一に、選考管理コンテキストが通知コンテキストの実装詳細に依存する。notificationService の引数や戻り値が変わると、選考管理コンテキストも変更を強いられる。第二に、通知処理が失敗したとき(メールサーバー障害など)、選考管理の操作全体がロールバックされる可能性がある。通知の失敗が選考プロセスの更新を取り消す理由はない。

ドメインイベントを使うと、この依存は逆転する。

// ドメインイベントを使った疎結合な設計
class ScreeningApplicationService {
  extendOffer(processId: string, salary: number): void {
    const process = this.repository.findById(processId)
    process.extendOffer(new Money(salary))  // 集約内でイベントを発行
    this.repository.save(process)
    // 選考管理コンテキストは通知コンテキストを知らない
    // イベントバスがイベントを配送する
  }
}

選考管理コンテキストはイベントを発行するだけであり、誰がそのイベントを受け取るかを知らない。通知コンテキストは OfferExtended イベントを購読して動作するが、選考管理コンテキストはその存在を意識しない。この非対称な関係が疎結合の本質である。

同期と非同期の比較

方式メリットデメリット
直接呼び出し(同期)実装がシンプルコンテキスト間の結合度が高い。障害が伝播する
ドメインイベント(非同期)コンテキスト間が疎結合。障害が局所化するイベントの配送・順序・冪等性の管理が必要

小規模なシステムでは直接呼び出しで十分なケースもある。ドメインイベントの導入はオーバーヘッドを伴うため、コンテキスト間の独立性を保つ必要性が明確なときに適用を判断する。


ドメインイベントの設計

基本構造

ドメインイベントは過去の出来事を正確に表す情報だけを持つ。将来の状態への期待や命令は含まない。

// ドメインイベント基底クラス
abstract class DomainEvent {
  readonly occurredAt: Date
  constructor() { this.occurredAt = new Date() }
}

// 具体的なドメインイベント
class OfferExtended extends DomainEvent {
  constructor(
    readonly screeningProcessId: ScreeningProcessId,
    readonly candidateId: CandidateId,
    readonly offeredSalary: Money,
  ) { super() }
}

occurredAt はイベントが発生した時刻を記録する。ログや監査、イベントの順序保証のために必要な情報である。screeningProcessIdcandidateIdofferedSalary は「どの選考プロセスで、誰に、いくらの内定が出たか」を過不足なく表す。

集約でドメインイベントを発行する

Vernonの設計では、ドメインイベントは集約の内部から発行される。集約はビジネスルールを守りながら状態を変化させ、その変化の記録としてイベントを蓄積する。

// 集約でドメインイベントを発行する
class ScreeningProcess extends AggregateRoot<ScreeningProcessId> {
  extendOffer(salary: Money): void {
    // 不変条件チェック
    if (!this.currentStage.isInterviewCompleted()) {
      throw new Error('Interview not completed')
    }
    this.addDomainEvent(
      new OfferExtended(this.id, this.candidateId, salary)
    )
  }
}

addDomainEvent は集約の基底クラス(AggregateRoot)が持つメソッドであり、発行されたイベントをリストに蓄積する。実際の配送はリポジトリ経由での保存完了後に行われる。これにより、データの永続化とイベント配送が同一トランザクションに収まる。

命名の実例

採用管理システムで使用するドメインイベントの命名例を示す。いずれも英語の過去形である。

イベント名意味
CandidateApplied候補者が応募した
DocumentScreeningCompleted書類選考が完了した
InterviewScheduled面接が設定された
InterviewCompleted面接が完了した
OfferExtended内定が出た
OfferAccepted内定が承諾された
OfferDeclined内定が辞退された

採用管理システムのドメインイベント一覧

各コンテキストが発行するイベントと購読するイベントを整理すると、コンテキスト間のデータの流れが明確になる。

イベント名発行コンテキスト購読コンテキスト購読側の処理
JobPostingPublished求人管理通知求人公開メールを送信
CandidateApplied候補者管理選考管理 / 通知選考プロセスを開始 / 応募確認メールを送信
DocumentScreeningCompleted選考管理通知書類選考結果メールを送信
InterviewScheduled選考管理通知面接日程メールを送信
OfferExtended選考管理通知内定通知メールを送信
OfferAccepted選考管理候補者管理 / 通知内定承諾を候補者履歴に記録 / 承諾確認メールを送信
OfferDeclined選考管理候補者管理 / 通知内定辞退を候補者履歴に記録 / 辞退確認メールを送信

通知コンテキストはほぼすべてのイベントを購読する。これは通知コンテキストがシステム全体のサポートサブドメインとして機能していることを示す。通知コンテキスト自身は他のコンテキストに対してイベントを発行しない。

選考管理コンテキストは多くのイベントを発行する中核的な上流コンテキストである。第4章で示したコンテキストマップにおける「公開ホストサービス」の役割と一致している。


第7章との接続:結果整合性をドメインイベントで実現する

Vernonは集約の設計原則として「複数の集約にまたがる一貫性は結果整合性(Eventual Consistency)で実現する」というルールを提示している。第7章で確認したこのルールは、ドメインイベントによって初めて具体的な実装手段を持つ。

同一トランザクションで複数の集約を変更することは、集約の境界が誤っているサインである。OfferExtended が発行されたとき、選考管理コンテキストの ScreeningProcess の状態変化と、候補者管理コンテキストの Candidate の記録更新は、別々のトランザクションで行われる。選考管理コンテキストは内定を出した事実をイベントとして発行し、候補者管理コンテキストはそのイベントを購読して自律的に状態を更新する。

// 通知コンテキストのイベントハンドラ
class OfferExtendedHandler {
  handle(event: OfferExtended): void {
    const candidate = this.candidateQuery.findById(event.candidateId)
    this.emailService.sendOfferEmail({
      to: candidate.emailAddress,
      processId: event.screeningProcessId.toString(),
      salary: event.offeredSalary.format(),
    })
  }
}

イベントハンドラはドメインイベントを受け取り、自コンテキストの責務だけを実行する。選考管理コンテキストの内部状態には直接アクセスしない。この設計が「コンテキストは自律的に動作する」というDDDの原則を実装レベルで支える。


次章への橋渡し

ドメインイベントを集約が発行する設計は、自然な問いを生む。「このイベントをデータベースに保存しておけば、状態の変化履歴がそのまま残るのではないか」。

この発想がイベントソーシング(Event Sourcing)である。従来のシステムが集約の現在状態を保存するのに対し、イベントソーシングは発行されたイベントの列を永続化し、現在状態はイベントを再生することで得る。

イベントソーシングはドメインイベントがなければ成立しない。ドメインイベントを設計に組み込んだことで、次章のCQRS(Command Query Responsibility Segregation)とイベントソーシングへの道が開かれる。


インフォグラフィック

サマリー

図: 採用管理システムにおけるドメインイベントの流れ

sequenceDiagram
  actor Recruiter as 採用担当者
  participant SP as ScreeningProcess<br/>(選考管理コンテキスト)
  participant Repo as リポジトリ<br/>(選考管理)
  participant Bus as イベントバス
  participant Handler as OfferExtendedHandler<br/>(通知コンテキスト)
  participant Email as メールサービス

  Recruiter->>SP: extendOffer(salary)
  Note over SP: 不変条件チェック<br/>addDomainEvent(OfferExtended)
  SP->>Repo: save(ScreeningProcess)
  Repo->>Bus: publish(OfferExtended)
  Note over Bus: イベントを非同期配送

  Bus->>Handler: handle(OfferExtended)
  Handler->>Email: sendOfferEmail(candidateId, salary)
  Email-->>Handler: 送信完了
  Note over Handler: 選考管理コンテキストとは<br/>独立したトランザクション