KAKEHASHI Tech Blog

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

アーキテクチャの進化はドメインイベントが起点になる

こちらの記事はカケハシ Advent Calendar 2023 Part2の24日目の記事になります。

adventar.org

はじめに

反復的な開発は、変更容易性の高いソフトウェアが不可欠です。ソフトウェア開発の経験がある方なら、デリバリ後の洞察や市場環境の変化から、新しい機能の追加やアーキテクチャの進化の必要性に直面したことが一度はあるでしょう。
私自身、要求分析手法やSOLID原則等の技法を取り入れ、変更容易性に対応する多くのプロジェクトに参加しました。しかし、どれだけ優れた手法や技法を持っていても、変更が難しい要求が出てくることは避けられません。その際、「過去の出来事」を正確に記録していれば、後から見返して問題解決が容易だったと感じることがよくあります。
ドメイン駆動設計(DDD)では、「過去に起こった出来事」を表現するドメインモデルを「ドメインイベント」と呼びます。変更容易性を追求する上で、まずドメインイベントの記録は重要だと私は考えています。後述する3つの論点のうち、少なくとも1つが該当する可能性がある場合、アプリケーションの実装にドメインイベントを取り入れるべきでしょう。これらの論点は、事前の要件分析では判断が難しい場合もありますが、システムが進化する中で頻繁にトピックに上る項目です。ドメインイベントを記録する構成にすることで、必要に応じてシステムを柔軟に拡張できます。 なお、本記事では拡張性を示すために、ドメインイベントの記録だけでなく、ドメインイベントによる伝達の観点も含めて説明します。

3つの論点

ここからは薬剤のECサイトを例にして考えてみましょう。薬剤のECサイトには注文機能と注文キャンセル機能があります。

1. 業務データを利活用するビジネスユースケースが想定できるか?

注文キャンセル機能を、注文リストからキャンセルがあった注文データを削除するといった実装対応をしていたとします。「特定の薬剤が含まれる注文をキャンセルした人に数日後に再度おすすめする」という要件が後から必要になった時には「注文キャンセル」の業務が記録されていないと実現が困難です。 未然にユースケースやユーザー体験の検討を進めていたら想定できたのかもしれませんが、事前分析をしても想定できない要件は往々にしてあります。

2. クエリ要件が複雑になる可能性があるか?

複数の薬剤を1度に注文する場合、「注文」に複数の「薬剤」を含めて記録します。この時、集約ルートは「注文」であり、コマンドの整合性を担保するためのコマンドモデルになります。一方、「過去に注文した薬剤」を薬剤名や用法、在庫状況といった属性を含めて一覧化する要件があった場合に、注文エンティティを経由するのはいささか面倒です。これは一例に過ぎませんが、このようにコマンド要件とクエリ要件は異なる場合があります。 CQRSのようにコマンドモデルとクエリモデルを隔離する時は、コマンドの結果をクエリモデルに反映する必要があり、ドメインイベントを契機にします。 実際に機能開発やモデリングしてみないと分からない場合があり、アプリケーションの成長とともに複雑化することは往々にしてあります。

3. コンテキスト境界をまたぐアプリケーションに対してデータを供給する予定があるか?

クエリの複雑性については、アーキテクチャレベルで隔離(Segregation)しなくても、1つのFrontend向き合いであればBFFやGraphQLでクエリモデルの複雑性を吸収する選択肢はあります。ただ、複数のコンシューマーに対するデータを供給するようなシナリオの場合は、データモデルを分離した方が拡張性が高いです。ドメインイベントをデータI/Fにして他のコンテキストに供給できます。イベントデータでなくても、アプリケーション内のステートテーブルをCDCを駆使してデータ提供をすることはできますが、データの業務知識が削ぎ落ちてしまいます。アプリケーション内のステートテーブルはそのアプリケーションコンテキストに最適化されているため、他コンテキストで扱いづらい場合があります。 他コンテキストへのデータの供給方法として、(Http) APIやgRPCの方法もありますが、次のような課題を抱えます:

  • コンシューマーが増えるたびにプロバイダー側がSPoFになる
  • 他のサービスを呼び出さなければ、本当の意味で機能しない (可用性依存)
  • どのような業務が行われたか(いわゆるドメインイベント)がプロバイダー側に隠蔽される
  • 分析用データを作る際は別の仕組みを構築する必要がある

なお、要件次第ではAPIやgRPCを採用した方がいい場合もあります。

ドメインイベントを記録するための構成

A. Event Sourcing

ドメインイベントを記録するだけの場合は、最初からCQRSのような構成を作る必要はありません。まずは図1のようにするのはどうでしょうか?

図1. Event Sourcing

Command側の業務ロジックでDomain Eventを生成し、EventStoreを経由してデータベースに記録します。データベースに記録する時はEvent Tableへの追記と、State Tableへの更新を同一トランザクションで行います。State Tableはドメインの集約の状態が保持されます。 この構成において、Event TableがSSoTになるためにState Tableで一貫性を保っています。Event TableとState Tableが別々のデータベースの場合は多層コミットになってしまいます。 この時点では特にEvent Tableは利用していませんが、拡張の備えとして記録しています。

B. CQS + Event Sourcing

私のおすすめは、図2のような「CQS + Event Sourcing」の構成から始めることです。 この構成はQuery Model ResolverからState Tableに矢印が伸びていて依存関係があります。このような構成はCommandとQueryが隔離(Segregation)していませんが、CommandとQueryで責任の分離(Separation)は行っていると言えるでしょう。つまりCQRSではなくてCQSです。「A. Event Sourcing」と違うのはQuery UseCaseをQuery Modelで賄っている点ですが、大きな構造の変更はありません。モジュールレベルで分割していればいいだけです。

図2. CQS + Event Sourcing

ドメインイベントを記録するための構成 (スケーラビリティの向上)

C. CQRS + Event Sourcing

「B. CQS + Event Sourcing」からアーキテクチャレベルでCommandとQueryの責任を隔離し、それぞれの要求に対する柔軟性をもたらすために、図3の「C. CQRS + Event Sourcing」を検討してみます。

図3. CQRS + Event Sourcing

「B. CQS + Event Sourcing」との差分は、次の2点です。

1. State Tableではなく、Snapshot Tableとして扱い、Aggregate ResolverでSnapshot TableとEvent Tableからドメインモデルの再生をしている点

EventStore時に、Snapshot TableへのI/Oは必須ではありません。Aggregate ResolverではSnapshot Tableに記録されたシーケンス番号から、差分になっている最新のイベントを取得し、最新の集約状態に再生します。

2. Event BusとRead Model Updaterを経由して、Query Model Tableを更新している点

変換層Read Model Updaterを用意することでCommandとQueryを隔離しています。この時、CommandとQueryで別のデータベースを選定しても構いません。ドメインイベントを起点に非同期にQuery Model Tableを更新するため、2者間は結果整合性になります。

「C. CQRS + Event Sourcing」は「B. CQS + Event Sourcing」と比べてCommandとQueryの柔軟性が上がり、スケーラブルな構成を手に入れられますが、次のトレードオフを許容する必要が出てきます。

  • ドメインイベントの仕様変更やフォーマットが変わった時に後方互換性やマイグレーション、エラー時の補正の複雑性
  • CommandとQueryで強い整合性を担保する特性のアプリケーションでは利用できない

なお、図3のような構成はEventStoreがイベント記録と伝達の2way commitを行う課題があります。Event Busがエラーになった時に、Event Tableをロールバックすることが困難で、分散トランザクションを実現しなくてはなりません。 また、分散トランザクションを回避するためにイベント伝達だけを行い、Sourceデータベースに書き込むアプローチもありますが、書き込みが遅延し、Commandの一貫性の担保が困難になります。

D. CQRS + Event Sourcing (with Database CDC Streaming)

図4. CQRS + Event Sourcing (with Database CDC Streaming)

「CQRS + Event Sourcing (with Database CDC Streaming)」では2way commit問題を解消できます。データベースのCDCを利用してイベントの伝達を行うようにしました。
ただし、イベントの記録を契機にイベントをCDCできるデータベースサービスが限られている点が課題です。 AWSだとDynamoDB Streamを使うことで、ドメインイベントをストリーミングで伝達できます。リレーショナル・データベースの場合はPostgreSQLだと論理レプリケーションを使って実現ができます。PostgreSQL以外の場合はSQL Triggerを使うことで実現はできますが、データベースの可用性が損なわれた時のメッセージングの信頼性がなく、可読性・メンテナンス性を維持することが難しくなります。 そもそもですが、Event TableとSnapshot Tableでは強い整合性がとれている必要がありますが、Snapshot Tableに関してはアプリケーション内で組まれるドメインロジックやデータ構造に最適化したデータベースが選定されるべきです。これらの理由から「イベントの記録・伝達」起点でデータベースサービスを選定すると不都合が生じるときがあります。

ドメインイベントを伝達するための構成

前項では、高いスケーラビリティを担保するソリューションとしてEvent Sourcingを紹介しましたが、この項ではドメインイベントの伝達に着眼した構成を紹介します。

E. CQRS + Transactional Outbox (with CDC)

このパターンではドメインイベントのテーブルをOutboxとし、CDCツールでポーリングしてドメインイベントの伝達を行います。CDCツールは、オープンソースであればDebezium、AWSであればDMSが有力な選択肢です。
前項でCQRSを取り上げたので本パターンでもCQRSをそのまま採用していますが、「A. Event Sourcing」でも、「B. CQS + Event Sourcing」でも、Transactional Outbox (with CDC)の方法を使ってドメインイベントの伝達は可能です。
毎回Event TableとState TableにI/Oを行う必要はあるので、CQRS + Event Sourcingほどのスケーラビリティはありませんが、データベース固有でないCDCのツールを使う点で、データベース選定の柔軟性が高いソリューションと言えます。 図5の例では、ドメインイベントをI/Fをして扱い、複数の宛先にデータ連携を行っています。CDCでドメインイベントを受け取った後に、Normalize ETLを中間に中継してCDCの出力を標準化し、Load先を二手に分岐させています。1つ目のLoad先である(Streaming) Read Model UpdaterはよりQuery Modelに変換するのに特化した責務になります。2つ目のLoad先であるEvent BusはConsumer Applicationにドメインイベントを供給するための中継を行います。これはドメインイベントを起点に拡張可能という点で、CやDのCQRS + Event Sourcingパターンでも応用が可能です。

図5. CQRS + Transactional Outbox (with CDC)

図5は横幅が広くて見づらいので、データパイプラインの箇所を拡大した図5.1を用意しました。

図5.1. データパイプラインの箇所

F. CQRS + Transactional Outbox (with CDC) + Data Lakehouse

最後に「E. CQRS + Transactional Outbox (with CDC)」をベースにしてデータ利活用のために拡張した例を挙げます。ドメインイベントを起点にSourceアプリケーション内のアーキテクチャを進化させるだけでなく、Consumerアプリケーションやデータ分析基盤にもシームレスに連携を行うことができます。

図6. CQRS + Transactional Outbox (with CDC) + Data Lakehouse

図6は横幅が広くて見づらいので、データパイプラインの箇所を拡大した図6.1を用意しました。

図6.1. Data Lakehouseの箇所

LakehouseでBronze、Silver、Goldの3層を設けてメダリオンアーキテクチャを実現する例として上げています。 Silver層ではData Vault、Gold層ではスタースキーマを扱っていますが、この説明だけでもかなりの文章量になるので概念の詳しい説明はこの記事では割愛します。ここではドメインイベントを起点にして実現していることだけをピックアップします:

  • ドメインイベントを受け取ってStreamingでそのまま各レイヤーに適用ができる
  • CDC経由で受け取ったドメインイベントをContents Enrichして供給するためにSilver層からデータをJOINすることができる

まとめ

ドメインイベントを起点にして、アーキテクチャ構成を進化させる過程と概念を紹介しました。
ドメインイベントを記録するだけの最低限のCQSの構成を小さく作り、大きな構造の変化をせずに、Sourceアプリケーションの責務の分離・隔離、そして、データ供給・利活用のための拡張をする例を取り上げました。 次回、おそらく年明け頃に、「Outbox Patternでのドメインイベントのモデリング技法」で、ドメインイベントをどう組み立てるかについて紹介します。

文責:木村 彰宏 (@kimutyam)

謝辞

この記事はもともと、社内でState Sourcingを用いて開発されたサービスが拡張性に関するいくつかの課題に直面していたことを契機に、カジュアルなEvent Sourcingの導入とその拡張方法について解説するための社内ドキュメントとして作成しました。この内容を外部に公開するにあたり、Event SourcingやCQRS、Outboxといった概念を正確に伝える必要がありました。そこで、ドメイン駆動設計やCQRS/Event Sourcingに関して豊富な知識と経験をお持ちの@j5ik2oさんにレビューを依頼しました。@j5ik2oさんのご指導のおかげで、以前は曖昧だった概念を深く理解することができ、大変感謝しております。貴重なお時間割いていただき、心より感謝申し上げます。

参考文献