KAKEHASHI Tech Blog

カケハシのEngineer Teamによるブログです。

ドメインイベントを伝達するためのモデリング技法

前回は、アーキテクチャの進化はドメインイベントが起点になるという記事内で、ドメインイベントの重要性を語りました。本稿では、ドメインイベントを伝達する際にシステム要件を満たした上で、どのようにしてデータモデル並びにドメインモデルを象るかを説明します。

なお、ビジネスドメインを深掘りドメインモデルを探索する手法の説明は、世にたくさん解説されているため詳しくはそちらに譲ります。特にAlberto Brandolini氏が提唱するモデリング手法であるEvent Stormingは、ワークショップ形式でドメインイベントを深く理解し、一連の業務プロセスやドメイン領域を探索的に発見することができる手法であり、Event Sourcingを前提とするアーキテクチャと相性がいいので参考にするとよいでしょう。

ドメインイベントのデータモデルの属性

ドメインイベントの記録および伝達に着眼した構成を紹介した前回の記事では、アプリケーションの実装にドメインイベントを取り入れるべき条件を3つの論点にまとめて説明しました。ここではドメインイベントをアプリケーション内でデータとして記録し、伝達することを考慮してドメインイベントのデータモデルに必要な属性を明らかにします。
Reliable Microservices Data Exchange With the Outbox PatternではCDCツールであるDebeziumを使ったOutboxパターンの紹介をしていますが、記事内で出てくるEvent Tableでは次の属性が含まれています。

属性 説明 利用用途
イベントID イベントそれ自体の一意の識別子 コンシューマーで重複排除する時
集約ID ドメインイベントが生成される大元の集約の一意な識別子 - メッセージキューでのコンパクション
- メッセージキューやデータベースのテーブルでのパーティショニング
集約タイプ イベントを生成した大元の集約 関心のあるコンシューマーに集約単位でルーティング
タイプ イベントのタイプ 関心のあるコンシューマーにイベント単位でルーティング
ペイロード 具象のドメインイベント個別のプロパティ群を含むペイロード コンシューマーが読み取り可能な形式で交換

Debeziumの記事内では、これらの属性を含む単一のテーブルを作成し、そのテーブルをOutbox Tableとして設定しています。そして、次のワークロードでデータ供給を行っています。:

  • Sourceアプリケーション内の同一トランザクション内で集約の保存・更新、ドメインイベントの保存・削除を実行
  • KafkaでドメインイベントのテーブルのCDCを受け取る
  • KafkaではCDCデータから保存イベントだけを抽出する (削除イベントは除外する)
  • Kafkaが特定の集約タイプに関するルーティングロジックを担い、コンシューマーに供給する

ドメインイベントの削除については、ドメインイベントの供給方法が確立できていれば、Sourceアプリケーション内で利用しないイベントデータをわざわざ保存しておく必要がないということでしょう。ドメインイベントはSourceアプリケーション内のあらゆる業務が記録されるので放っておくと大きな時系列データを運用することになります。その複雑性を軽減するために削除する設計上のオプションがある程度に捉えるといいでしょう。
さて、本題の"必要な属性"に話題を戻します。上で紹介したテーブル属性に次のような追加(および、一部変更)した方がいい属性を紹介します。

イベント時刻

イベント時刻は、ドメインイベントの発生した時刻を表す属性です。発生した順序でソートしたり、期間でクエリする用途で利用します。データベースにおけるドメインイベントのテーブルで参照コストを軽減するために時系列パーティショニング等で指定するプロパティとして使うこともあります。ところで、紹介したDebezium記事内では、タイムスタンプはKafkaのタイムスタンプ、つまりKafkaに取り込まれたタイムスタンプを利用しているようです。これでもいいのですが、取り込まれる時刻と業務が発生した時刻との差異や、取り込まれる順序が異なる場合のことを踏まえると、Sourceアプリケーション内でイベント時刻を評価し、イベントテーブルに記録するのがいいと考えています。また、イベント時刻は、IDから算出することもできる場合もあります。例えば、ULIDはタイムスタンプを内包したID体系なのでデコードができます。JavaScriptのライブラリであるulidxではULIDをミリ秒のタイムスタンプに変換する関数が実装されています。ドメインイベントIDからタイムスタンプを逆算出来る場合はドメインイベントの属性として定義するのは必須ではありませんが、ID体系が変わった時に互換性が損なわれるを考慮し、明示的にデータモデルの属性として持っていたほうがよいでしょう。

シーケンス番号

シーケンス番号は、集約を論理的に正しい順序で記録し、冪等に伝達する用途で利用するプロパティです。分散システムでは時刻の前後関係を比較することが完全にはできないため、イベント時刻を使って再生するのは厳密性に欠けます。(関連: 分散システムにおけるイベントの前後関係と論理時計・ベクトル時計について)
シーケンス番号の状態は集約が保持していて、その集約からインクリメントしたものをドメインイベントのプロパティの値とします。シーケンス番号を元に集約IDと集約タイプに対して順序通りにイベントを再生し、集約のステートを再現します。

イベント名

先程の表の「タイプ」はイベントそのものを表していたので、以降は「イベント名」と呼ぶこととします。イベント名は、例えば、"注文依頼"、"注文キャンセル"といった文字列で表現される属性です。データの監査観点においても重要な属性です。

集約名

先程の表の「集約タイプ」は大元の集約が何であるかを示す役割も果たしますが、これは種別というより名前と表現するのが自然ですので、以降は集約名と呼ぶこととします。例えば、ドメインイベントを生成する大元の集約の名前です。「注文依頼」や「注文キャンセル」といったドメインイベントの大元の集約名は"注文"になります。集約名は、イベント再生時にどの集約に対するイベントであるかを明らかにします。また、集約単位でのイベントルーティングを可能とします。ただし、「注文依頼」「注文キャンセル」といったドメインイベントの単位でルーティングを切り替えることはできません。このように詳細なルーティングをしたい場合は「イベント名」を利用します。

ハッシュ化済み集約Key

分散メッセージキューや分散DBにおいてパーティショニングをする際もハッシュ化した方が分散効率がよくなります。この属性自体はスループットのためのオプションです。

これらを踏まえて属性表を再整理します。

属性 説明 利用用途
イベントID ID (String or Number) イベントそれ自体の一意の識別子 べき等のコンシューマーの重複排除
イベント時刻 Number イベントが発生した時刻 - クエリ
- テーブルパーティショニング
シーケンス番号 Number 集約に関するシーケンス - イベント再生
- イベント伝達時の冪等性担保
イベント名 String このイベントが何であるかを表す - 関心のあるコンシューマーにイベント単位でルーティングする
- イベントそのものを識別する
集約ID String ドメインイベントが生成される大元の集約の一意な識別子 - イベント再生
集約名 String イベントを生成した大元の集約名 - イベント再生
- 関心のあるコンシューマーにイベントをルーティング
ペイロード JSON 具象のドメインイベント個別のプロパティ群を含むペイロード コンシューマーが読み取り可能な形式で交換
ハッシュ化済み集約Key String ドメインイベントが生成される大元の集約の一意なキー - メッセージキューでのコンパクション
- メッセージキューやデータベースでのパーティショニング
※ この属性はOptionです。

データモデリング

このデータモデルは、ドメインイベントを伝達する際にデータインターフェースの役割を果たします。ペイロードはJSONのような交換形式で格納します。ドメインイベントの具象によってこのJSONの形状は異なります。ドメインイベントは保存したとしても、アプリケーション内ではイベント再生以外の用途で参照ユースケースはほぼありません。CDCのことを考慮してこのデータモデルを単一テーブルに書き込む設計にするのがよいでしょう。

書き込み先のデータベース

アーキテクチャの進化はドメインイベントが起点になるにおける「D. CQRS + Event Sourcing (with Database CDC Streaming)」のような構成の場合は、書き込みのスループットが高く、CDCが備わっているDynamoDBなどを選定するのがよいでしょう。ドメインイベントを伝達するための構成である「E. CQRS + Transactional Outbox (with CDC)」や「F. CQRS + Transactional Outbox (with CDC) + Data Lakehouse」の場合は、データベースの選定に柔軟性があります。仮に例えば、リレーショナル・データベースを選択している場合は、次のようにドメインイベントテーブルを規定テーブルとして、複数のテーブルに正規化して表現する設計が考えられます。

※ 上記のER図は集約テーブルを省略しています。

しかし、先程も説明したように参照ユースケースがほぼ存在しないので、複数テーブルにするのはROIが見合わない上で、Outboxテーブルの指定が複雑になるため、リレーショナル・データベースにおいても単一テーブルに書き込むのがよいと考えます。以下は、ドメインイベントをリレーショナル・データベースに記録するアプローチの比較表です。

pros cons
単一テーブル - CDCツールに対してOutboxテーブルの指定が1つだけで簡単である
- 「集約名」によるコンテンツベースルーティングがしやすい
- コンシューマー側で「集約名」ごとに分岐ロジックを組む必要がある (プログラム内の関数の分割で解消は可能である)
- 集約IDについて外部キー制約を定義できない
- ペイロードのスキーマをイベントごとに定義できない
複数テーブル - ソースアプリケーション内でイベントテーブルモデルの視認性が高い
- 「集約名」毎にイベントハンドラを用意できる
- Outboxテーブルの指定が多く、具象のイベントテーブルが増えるたびに対応が必要
- オブジェクトや配列のようなデータ構造を持つ時に、結局JSONのような属性を持つか、1:Nのテーブルを別途作る必要が出てくる
- 規定テーブルの変更が全テーブルに波及し、イベントハンドラの修正範囲が大きくなる

ドメインモデリング

今までの前提を踏まえ、薬剤の注文サービスを想定したドメインモデルのサンプルを展開します。

この例では、集約は「薬剤」と「注文」です。
「注文」における「依頼する」関数の戻り値は、自分自身と「注文依頼」で、「キャンセルする」関数の戻り値は、自分自身と「注文キャンセル」です。(※1)
「薬剤」における「登録する」関数の戻り値は、自分自身と「薬剤登録」で、「名前を変更する」関数の戻り値は、自分自身と「薬剤名変更」です。(※1)
このようにドメインイベントは集約のデータ構造から生成します。(厳密にはドメインイベントIDは存在しないので、関数の引数にドメインイベントIDを生成する関数を渡すようなアプローチが必要になります。) 集約が自分自身を戻り値に含めているのは、振る舞いによって変更のあったステートを反映させるためです。(集約オブジェクトのステート変更をイミュータブルに行うために、自分自身の型を戻り値に利用する「閉じた操作」というデザインパターンを使っています。)
シーケンス番号は、集約が初めて状態を作るドメインイベント(例えば、「注文依頼」と「薬剤登録」)で初期化され、集約の状態に対する変更を行うドメインイベント(例えば、「注文キャンセル」と「薬剤名変更」)でインクリメントします。
なお、ここではハッシュ化済み集約Keyやペイロードの属性は概念として登場していませんが、それはドメインロジックを実行して得られたドメインイベントや集約を、どのように保存するかについてはデータベースアダプタの責務だからです。

※1 説明の簡略化のために成功した場合の戻り値として説明しています。関数を実行した結果、集約の状態が不正になる場合は、集約は状態遷移せず、ドメインイベントも発行されません。

ドメインイベントには集約のプロパティをどこまで含めるか?

結論から言うと、ドメインイベントを表すプロパティのみを含めます。

さきほどのUMLで記載していた「薬剤名変更」にフォーカスして考えてみます。「薬剤名変更」の属性の内、ドメインイベントID・シーケンス番号・イベント名・集約ID・集約名はドメインイベントの基底属性で、薬剤名だけがこのドメインイベントの個別の属性です。集約「薬剤」に含まれている単価は、「薬剤名変更」には含まれていません。「薬剤名変更」の業務を表すのに必要のない属性だからです。
一方、次のような一部のユースケースで、集約のプロパティを全て含める設計が有効になる場合があります。:

  • KafkaのようなEvent Busのログコンパクションを使って、「ハッシュ化済み集約Key」で最新のタイムスタンプのログだけにコンパクションし、ログの総量を減らす
  • データ分析用のRaw Data Vaultを保存する際に集約のルックアップを省略する

ただし、コンパクションは「ペイロード」の情報が削ぎ落とすことを意味しますし、集約のステートを含めると1つあたりのメッセージが肥大化し、Event Busによっては制約を超えるサイズになる場合もありえるので、特別な非機能要件がある場合にのみ検討するとよいでしょう。

ドメインイベントを記録する

アーキテクチャの進化はドメインイベントが起点になるで取り上げた「A. Event Sourcing」と「B. CQS + Event Sourcing」では次のようにドメインイベントと一緒に集約を保存します。

実装ではドメインイベントの保存と、集約の保存・更新・削除のトランザクション処理をします。 CQRSパターンである「C. CQRS + Event Sourcing」や「D. CQRS + Event Sourcing (with Database CDC Streaming)」において、ドメインイベントとスナップショットを一緒に記録する場合は同様のインターフェースになりますが、基本的には次のようにドメインイベントだけを記録するのが基本になります。

まとめ

ドメインモデルにおけるドメインイベントは業務そのものを表すユビキタス言語であり、ドメインイベントのデータモデルは外部とのインターフェースの役割を果たします。ドメインイベントを介してコンシューマーにおける様々な利用用途をカバーできます。 データを供給するための仕組みを構築するのは多少の難しさがありますが、今回説明した内容をアプリケーションに適用すること自体はさほど難しいことではありません。
この記事がイベントソーシングでドメインイベントの記録を行う際に設計の参考になっていただけることを願って締めとします。

文責: 木村彰宏 (@kimutyam)

謝辞

この記事はもともと、アーキテクチャの進化はドメインイベントが起点になるに続き、社内でEvent Sourcingを導入する際に具体的なシステムの設計に落とし込む上でどういう観点が必要であるかを解説するための社内ドキュメントとして作成しました。より正確な内容を公開するために、前回に引き続き、ドメイン駆動設計やCQRS/Event Sourcingに関して豊富な知識と経験をお持ちの@j5ik2oさんにレビューを依頼しました。再度、貴重なお時間割いていただき、心より感謝申し上げます。
また、レビューの際に、@j5ik2oさんがコントリビュートしているevent-store-adapter-jsを共有いただきました。これはDynamoDBをCQRS/Event Sourcing用のEvent Storeにするためのライブラリで、JavaScript(TypeScript)だけでなく様々な言語に対応した版もあります。このライブラリはCQRS/Event Sourcingのアーキテクチャを採択する際に活用できますし、中身の実装も参考になるのでこの場で紹介します。

参考文献