HN 日本語サマリー

← 一覧へ戻る
プログラミング

インテグレーションイベントによる境界の越境

Crossing Boundaries with Integration Events (deniskyashif.com)

9 pointsby alembic_fumes0 コメント

要約

この記事は、ドメイン駆動設計(DDD)におけるインテグレーションイベントの重要性について解説しています。異なる境界を持つコンテキスト間で情報をやり取りする際に、ドメインイベントをそのまま公開するのではなく、外部向けに設計されたインテグレーションイベントとして定義することの利点を説明しています。これにより、コンテキスト間の結合度を低く保ち、各コンテキストの独立性を維持しながら、信頼性の高い非同期通信を実現する方法を探ります。

全文翻訳

境界を越えるインテグレーションイベント

この記事は、ドメイン駆動設計(DDD)に関する継続的なシリーズの一部です。
シリーズの他の記事はこちらで確認できます。

この記事では、あるバウンデッドコンテキストが別のコンテキストに何かが起こったことを伝えるために、すべてがどのように正しく行われるべきか、そしてそれがうまくいかない場合にどのように設計するかについて説明します。

モデリングファクトとリアクションのためのドメインイベントでは、ドメインイベントがその事実が真実になったモデル内で意味のあるビジネスファクトを記録する方法を見ました。それはローカルな反応を引き起こす可能性がありますが、その名前、型、ペイロードはドメインモデルに属し、モデルと共に進化する自由があります。ドメインイベントを内部イベントと考えることができます。ここでは、事実が別のバウンデッドコンテキストに移動する必要がある場合に何が起こるか、つまり独自の言語と責任を持つモデルについて説明します。

境界を越える

注文管理(Ordering)と出荷(Fulfillment)が独立して所有・デプロイされたコンテキストであり、出荷が新しく行われた注文を準備する必要があると仮定します。それらは、注文管理の内部モデルに依存させずに、出荷が必要とするビジネス情報を含む安定した公開契約を必要とします。この契約がインテグレーションイベントであり、これは外部イベントと考えることができます。

[画像: 2つのコンテキスト間のインテグレーションイベント]

インテグレーションイベントは、Apache Kafka、RabbitMQ、Azure Service Busなどのインフラストラクチャを使用して、耐久性のある非同期メッセージングを介して移動するため、注文管理は出荷を待たずに完了できます。各コンテキストは独自のペースで作業を処理でき、一時的な障害が出力元に伝播する必要はなく、コンシューマーが処理できるまでメッセージは利用可能なままになります。

これらの利点には新たな課題が伴います。どちらのコンテキストも利用できなくなる可能性があり、メッセージが遅延または重複する可能性があり、2つのコンテキストはデータベーストランザクションを共有できません。したがって、解決すべき2つの問題があります。

  1. 契約の設計
  2. 信頼性の高い配信

境界のための契約設計

ドメインモデルを公開しない

このモデルで、注文を行うことが出荷への移行準備を整え、注文管理がそのイベントが発生したときにこのドメインイベントを発生させると仮定します。

OrderPlaced
  orderId: OrderId
  customer: CustomerId
  items: List<OrderItem>
  placedAt: Timestamp

OrderItem
  productId: ProductId
  quantity: Quantity

このイベントは、注文管理の内部ハンドラ用に設計されています。ProductId、Quantity、その他の型指定された識別子は、注文管理のモデルに属する値オブジェクトであり、モデルと共に変更される自由があります。

イベントを直接公開することは、これらの内部情報を公開契約にすることであり、おそらくイベント駆動型統合で最も一般的な間違いです。「再利用」のように感じられます。イベントはすでに存在するので、共有しない理由はありませんか?しかし、今ではすべてのコンシューマーが注文管理のモデルに依存しており、注文管理自身のニーズのために行われたすべてのリファクタリングが他のチームを壊し始めます。ペイロードは境界にも不適切です。出荷が必要としないcustomerを公開し、注文管理の内部識別子を使用して製品を説明しています。

代わりに、注文管理は同じ事実を、出荷コンテキスト用に設計されたインテグレーションイベントに変換できます。

{
  "metadata": {
    "type": "order-ready-for-fulfillment",
    "version": 1,
    "messageId": "01JZ2Q5Y7M8K9N0P1R2S3T4V5W",
    "occurredAt": "2026-08-29T09:42:18Z",
    "orderEventSequence": 4
  },
  "body": {
    "orderId": "ord-8472",
    "items": [
      { "productCode": "CHAIR-BLK", "quantity": 2 },
      { "productCode": "DESK-OAK", "quantity": 1 }
    ]
  }
}

このエンベロープは、トランスポートと契約のメタデータをビジネスペイロードから分離します。metadataはイベント契約を識別し、メッセージのルーティング、重複排除、解釈に使用される情報を含みます。bodyは出荷によって消費される事実を含みます。orderEventSequenceは、orderIdごとのプロデューサー割り当てのカウンターです。後でその有用性を見ていきます。

正確なJSON形式は本質的ではありません。一部のメッセージングプラットフォームはヘッダーにメタデータを配置し、CloudEventsのような標準は独自のエンベロープフィールドを定義しています。重要なのは、分離が意味論的であり、プロデューサーとコンシューマー間で一貫していることです。均一なエンベロープにより、メッセージングインフラストラクチャは、各ボディを理解することなく、トレーシングや重複排除などの懸念に対処できます。

OrderPlacedドメインイベントとこのインテグレーションイベントは、名前や形状を共有せずに同じ発生を記述しています。インテグレーションイベントは、注文管理の値オブジェクトクラスではなく、安定したシリアル化された値を含み、出荷が必要としないcustomerをドロップします。

注文管理はこの境界固有の契約を所有しており、別の境界は同じドメインイベントから派生した異なるインテグレーションイベントを必要とする場合があります。ペイロードをコンシューマーが必要とする事実に限定することは、結合を制限します。プロデューサーの内部詳細を公開するほど、プロデューサー側のリファクタリングが波及する可能性は低くなります。どのくらいのデータを運ぶか、コンシューマーにそれを取得させるかについては、それ自体にトレードオフがあり、以下で説明します。

インテグレーションイベントは公開されたインターフェースです。別のコンテキストがそれに依存すると、変更はバージョン管理されたRESTまたはgRPC APIの変更と同じ互換性の懸念を伴います。そのスキーマ、フィールドの意味、保証、およびバージョニングポリシーは、そのインターフェースの一部になります。

内部状態変更ではなくビジネスファクト

名前は、ペイロードと同じくらい簡単に内部ドメインモデルを漏らす可能性があります。出荷が注文管理のために次のイベントを公開すると仮定します。

FulfillmentStatusChanged
  orderId
  oldStatus: PICKING // またはさらに悪い - 文字列ではなく数値キー
  newStatus: DISPATCHED

この契約により、注文管理は出荷のステータスを理解し、そのステートマシンの一部を再現する必要があります。注文管理が数個しか気にしない場合でも、すべての遷移を公開する可能性があります。ステータスを変更すると、ビジネスファクトが変わらない場合でも、すべてのコンシューマーに影響を与える可能性があります。契約は他のコンテキストを出荷の内部モデルに結合し、実装の詳細を漏らします。

非同期メッセージングは、それ自体では結合を削除しません。契約は依然としてビジネス上の意味を表現する必要があります。

同じやり取りは、注文管理にとって重要な事実として表現できます。

OrderDispatched
  orderId
  dispatchedAt

OrderDispatchedは、注文管理が出荷の内部ライフサイクルを解釈する必要なしに、何が起こったかを伝えます。ステータスの変更自体が意味のあるドメイン言語である場合、FulfillmentStatusChangedは本質的に間違っていません。フィールドを同期したり、実装の詳細を公開したりするだけの場合は問題になります。

データイベントはインテグレーション契約ではない

イベントソーシングと変更データキャプチャは独自のイベントストリームを生成し、それらをすぐに使用できるインテグレーションイベントとして扱う誘惑に駆られます。それらはそうではありません。イベントソースイベントは、内部状態を再構築するために使用される永続化レコードです。それはOrderPlacedのような本物のビジネスファクトを表現するかもしれませんが、その名前、スキーマ、および進化は依然としてプロデューサーのモデルと永続化のニーズに役立ちます。変更データキャプチャストリームはより低レベルです。それは、通常、ビジネス用語でなぜ変更されたのかを表現することなく、保存されたデータがどのように変更されたかを記述する、行レベルのデータベース変更フィードです。どちらのストリームも内部契約であり、公開インテグレーション契約ではありません。

OrderRow.status changed from 2 to 5のようなCDCレコードは、コンシューマーにステータス5が「フルフィル済み」を意味することを知らせ、データベース変更からビジネスファクトを推測することを強制します。イベントソースストリームを公開すると、より高い意味論レベルで同様の結合が発生します。コンシューマーは、プロデューサーがモデルを進化させるにつれて分割、マージ、または再形成する必要がある可能性のあるレコードに依存するようになります。どちらのストリームも、インテグレーションイベントが派生する内部ソースとして役立つことができます。

コンシューマーが追加データを必要とする場合

コンシューマーは、その事実に基づいて行動するために、プロデューシングコンテキストが所有するデータを必要とすることがよくあります。自律性と結合の間の異なるトレードオフを持つ3つの一般的なアプローチがあります。

  • イベントで状態を転送する(Event-carried state transfer): 各イベントには、その事実に反応するために必要なプロデューサー所有のデータが含まれます。コンシューマーはプロデューサーに呼び出すことなく行動できますが、ペイロードが大きくなり、データが重複するコストがかかります。上記のorder-ready-for-fulfillmentイベントはこのアプローチを取ります。出荷がカタログに問い合わせる必要がないように、アイテムを運びます。
  • 薄い通知とコールバック(Thin notification plus callback): イベントは識別子を運び、コンシューマーはプロデューサーの公開APIを介してプロデューサーに詳細を照会します。これにより、ペイロードは小さくなりますが、実行時依存関係が作成され、イベントが発生した時点から変更されたデータを返す可能性があります。これは、多くのコンシューマーがそれぞれ異なるデータを必要とする場合に適しています。全員を満足させるためにペイロードが肥大化するのではなく、各コンシューマーは必要なものだけを取得します。
  • ローカルレプリカ(Local replica): コンシューマーは、プロデューサーからの公開インテグレーションイベントストリームを処理することによって読み取りモデルを構築します。たとえば、出荷はカタログコンテキストのイベントからローカル製品テーブルを維持するため、注文ごとにカタログに呼び出すことなく重量と寸法を検索できます。プロデューサーの可用性に依存することなくそのデータを照会できますが、追加のストレージを操作および同期し、レプリケーションラグを許容する必要があります。

どのアプローチを選択しても、通知は所有権を転送しません。プロデューシングコンテキストはそのデータに対して権威を持ち続けます。

エッジでの翻訳

内部ファクトから公開契約への翻訳は、集約内ではなく境界で発生する可能性があります。

  • アプリケーションまたはインフラストラクチャレイヤーがドメインイベントをインテグレーションイベントにマッピングします。
  • 集約はインテグレーション契約を認識せず、ドメインイベントのみを発生させます。
  • 事実がバウンデッドコンテキストから出る必要がない場合、ドメインイベントはインテグレーションイベントを生成しない可能性があります。
  • 単一のドメインイベントが複数のインテグレーションイベントを生成することもできます。

[画像: バウンデッドコンテキストの境界でドメインイベントがインテグレーションイベントに翻訳されることを示す同心円状のアーキテクチャレイヤー]

on OrderPlaced event:
  message = OrderReadyForFulfillmentV1(
    messageId = newId(),
    orderId = event.orderId,
    items = event.items.map(item -> {
      productCode = productCodeFor(item.productId),
      quantity = item.quantity.value
    }),
    occurredAt = event.occurredAt,
    orderEventSequence = nextSequenceFor(event.orderId))
  publish(message)  // この行が問題になりそう

このイベントハンドラは意図的に単純です。マッピング、フラット化、シーケンス番号の割り当てを行い、メッセージを公開のために渡します。集約が集約を発生させたときに事実がすでに決定されているため、ここではビジネス上の決定は行われません。それが、ドメインモデルをトランスポートの懸念から解放し、契約をドメインの内部から解放する理由です。

保存と公開の間のクラッシュを乗り越える

インテグレーションイベントを設計したら、それを配信する必要があります。翻訳ハンドラからズームアウトします。それは注文を行う一部として、集約が保存された直後に実行されます。エンドツーエンドのシーケンスは次のとおりです。

save(order)                       // コミット; OrderPlaced がハンドラをトリガー
message = translate(orderPlaced)  // ハンドラがメッセージを構築...
publish(message)                  // ...そしてそれを公開

これは記事の冒頭の失敗です。save(order)がコミットされ、プロセスがクラッシュし、イベントが建物から出なくなります。注文管理は注文が配置されたと見なし、出荷は決して知らされず、顧客は誰も準備していないパッケージを待ちます。

最初に公開してから保存するのはどうでしょうか?それを実行してみましょう。イベントが送信され、注文の保存が失敗し、出荷は存在しない注文を準備することになります。

問題は、データベースとメッセージブローカーが2つの独立したシステムであることです。ほとんどのアプリケーションでは、両方を単一のトランザクションでラップすることはできません。できることは、ドメイン変更と公開の意図を同じデータベーストランザクションでコミットすることです。これはトランザクショナルアウトボックスパターンの背後にある考え方です。

イベントを直接送信する代わりに、注文と同じデータベースに保存されているアウトボックステーブルにシリアル化します。

transaction:
  save(order)                 // OrderPlaced が発生し、ハンドラがメッセージを構築
  saveOutboxMessage(message)  // publish(message) の代わりに
commit

[画像: トランザクショナルアウトボックスフロー:注文とアウトボックスメッセージをアトミックに保存し、確認後に公開してメッセージをマークする]

注文とその公開の意図は、今や一緒にコミットまたはロールバックされます。別のパブリッシャーは、保留中のメッセージをポーリングし、それらを送信し、ブローカーが確認した後、公開済みとしてマークできます。

二重に到着するメッセージ

アウトボックスはイベントの損失を防ぎますが、二重到着を防ぐことはできません。パブリッシャーはメッセージを送信してから、アウトボックスレコードが公開済みとしてマークされる前にクラッシュする可能性があり、再起動時に同じメッセージを再度送信します。コンシューマー側にも同様のウィンドウが存在します。ハンドラは作業をコミットし、確認を失う可能性があり、ブローカーはメッセージを再配信します。これは少なくとも1回の配信と呼ばれます。

これは、コンシューマーが冪等処理を必要とすることを意味します。同じメッセージを繰り返し処理しても、一度処理するのと同じビジネス効果があります。一部の操作は自然に冪等です。たとえば、出荷先住所を特定の値に設定することは、毎回同じ結果になります。カウンターのインクリメント、在庫の予約、支払いの請求は通常そうではありません。

それ以外の場合は、すでに処理したメッセージを覚えておくことが明白な解決策です。興味深い質問は、そのメモリがどこにあるか、そしてそれをチェックした直後にクラッシュした場合に何が起こるかです。出荷は、重複排除キーとしてイベントのmessageIdを使用できます。受信ボックス、または単に処理済みメッセージ識別子のテーブルを保持し、トランザクション内で識別子を記録します。

on OrderReadyForFulfillment message:
transaction:
  if messageId was not already processed:
    prepareOrder(message)
    recordProcessed(messageId)
commit
acknowledge message

処理が失敗した場合、トランザクションはロールバックされ、メッセージは再試行できます。それでも失敗し続ける場合は、診断と制御された再生のための十分なコンテキストとともに、デッドレターキューまたは同等の障害ストアに移動する必要があります。処理が成功しても確認が失われた場合、次の配信では記録された識別子が見つかり、何も行われません。

1つの詳細は間違いやすいです。ビジネス変更と処理済みメッセージレコードは、同じデータベースへの2つの書き込みである必要があります。そのため、単一のトランザクションが両方をカバーします。処理済みmessageIdの一意制約は、2つのコンシューマーインスタンスが重複を同時に受信するのを防ぐこともできます。

顧客への請求を一度だけ行う

受信ボックスのトリックは、ビジネス変更と処理済みメッセージレコードが同じデータベースへの2つの書き込みであるため、単一のトランザクションが両方をカバーするため機能します。コンシューマーのイベントハンドラがデータベース外の何かを行うと、その保証は失われます。たとえば、注文の準備が出荷プロバイダーに請求することも含まれるとします。プロバイダーへの呼び出しはトランザクションの一部ではないため、2つの独立したシステムを調整することに戻ります。

受信ボックスは受信メッセージを重複排除しますが、それはコンシューマー自身のデータベースを保護するだけです。再配信されたメッセージが支払いプロバイダーに2回呼び出されるのを防ぐことはできません。ハンドラは未処理のmessageIdを見て、顧客に請求し、処理済みとしてメッセージを記録する前にクラッシュします。再配信時に、再度請求します。設計上のギャップは払い戻しチケットになります。

支払いプロバイダーも独自の保護が必要です。そのため、出荷は冪等性キーを呼び出しと共に送信します。これは、プロバイダーが繰り返しを認識し、一度だけ請求できるようにする安定した識別子(単にmessageIdでよい)です。1つのキーが入り口を保護し、もう1つのキーが出口を保護します。また、呼び出しを永続的にする決定を行うために、出荷はアウトボックスとして独自のコマンドを記録し、ビジネス変更と同じトランザクションでコミットします。別のステップがアウトボックスをドレインし、呼び出しを行います。これは、プロデューサーがブローカーで使用したのと同じ境界処理を適用します。

// コンシューマーハンドラ
transaction:
  if messageId not processed:
    prepareOrder(message)
    recordProcessed(messageId)
    saveOutboxCommand(ChargePayment, idempotencyKey = messageId)
commit

// 別個のパブリッシャー、後で
for each pending command:
  call provider with command.idempotencyKey   // 再試行しても安全
  markSent(command)

アウトボックスは呼び出しが決して失われないことを保証します。冪等性キーは呼び出しが決して二度適用されないことを保証します。これはもちろん、支払いプロバイダーが独自の冪等性キーをサポートしていることを前提としています。

キャンセルの到着が先の場合

メッセージは順不同で到着することもあります。出荷は無関係な注文が順序で到着するかどうかを気にしませんが、同じorderIdに関するイベントでは順序が重要です。たとえば、orderEventSequence: 5を持つOrderCancelledが、orderEventSequence: 4を持つ遅延したOrderReadyForFulfillmentよりも前に到着する可能性があります。古いイベントを処理すると、倉庫作業員が数分前に顧客がキャンセルした注文をピッキングして梱包することになります。

orderIdをブローカーのパーティションまたはセッションキーとして使用すると、注文ごとの配信順序を維持するのに役立ちます。プロデューサー割り当てのorderEventSequenceは2番目の保護を提供します。シーケンス5を記録した後、出荷は古いものとしてシーケンス4を無視できます。重複排除は1つのメッセージが二度効果を発揮するのを防ぎます。シーケンスチェックは、古いメッセージが新しい事実を元に戻すのを防ぎます。

インテグレーションイベントが適さない場合

インテグレーションイベントは、コンテキストが事実を公開し、応答を待たずに作業を完了できる場合に適しています。続行するために即時の回答が必要な場合は、直接APIの方が依存関係が明確になります。

ブローカーを介してリクエストを送信しても、イベントにはなりません。特定の受信者にアクションを依頼するメッセージはコマンドであり、情報を要求するメッセージはクエリです。送信者が応答を期待している場合、やり取りは依然としてリクエスト・レスポンスです。前述のコールバックはそのようなクエリです。イベントによってトリガーされますが、イベント契約の一部ではありません。

メッセージングは魅力的な選択肢ですが、システムを自動的に分離するわけではありません。また、アウトボックス、受信ボックス、シーケンスチェックなど、考慮すべき多くのケースが追加されます。コンテキストが独立して動作する必要がある場合や、複数のコンシューマーが同じ事実を必要とする場合には価値があります。それ以外の場合は、直接呼び出しの方が簡単な場合があります。

結論

インテグレーションイベントにより、バウンデッドコンテキストは内部モデルを公開することなくビジネスファクトを共有できます。その独立性は、境界の両側に依存します。プロデューサーは安定した契約を確実に公開する必要があり、コンシューマーは重複、遅延、順不同配信を許容する必要があります。これらのトレードオフが正当化される場合、インテグレーションイベントはコンテキストが独立して進化および運用するための耐久性のある方法を提供します。

関連記事

  • What do you mean by “Event-Driven”? by Martin Fowler
  • Enterprise Integration Patterns by Gregor Hohpe and Bobby Woolf
  • Designing Data-Intensive Applications by Martin Kleppmann; Chapter 11 - Stream Processing