
はじめに
こんにちは。基幹システム本部・リプレイス推進部・リプレイス推進ブロックの岡本です。
私たちのチームでは、ZOZOの基幹システムリプレイスの一環として、会計領域のシステムを新規構築しています。アーキテクチャにはCQRS(Command Query Responsibility Segregation)+ES(Event Sourcing)を採用しました(以降、CQRS+ESと略記します)。
本記事では、CQRS+ESを実務へ適用する中で直面した「小さな集約を保ちながら、大量の集約をまたいだ業務出力をどう実現するか」という課題と、その解決で得られた知見を紹介します。
会計システムでは、決済に関連する明細データを決済ID単位の小さな集約(Aggregate)として設計しています。一方で、消込結果を月次でまとめた帳票を出力するようなユースケースでは数万件規模の集約を横断する必要があり、集約の境界と業務出力のスコープに不一致が生じます。この不一致により、Sagaによる協調の結果を1つのイベントでQuery側に届ける必要が生まれ、イベントペイロードの肥大化が問題となりました。私たちはこの問題を共有テーブルとシグナルイベントを組み合わせたパターンで解決しました。
なお、本記事で述べる会計システムの仕様は、実装上の問題構造を説明するために簡略化・抽象化したものであり、実際のシステム仕様とは異なります。CQRS+ESを実務に適用する中で同様の課題に直面している方々の一助となれば幸いです。
目次
- はじめに
- 目次
- 背景
- なぜCQRS+ESを選んだか
- インフラ構成の選択 ── RDB 1つでCQRS+ESを実現する
- 集約の境界と業務出力のスコープの不一致
- Sagaで複数集約を協調させる
- Query側へのデータ伝達 ── イベントに載せきれないとき
- CQRS+ESを実践してみて
- まとめ
背景
基幹システムリプレイスの概要
ZOZOの基幹システムは、20年以上にわたり機能追加を重ねてきた大規模モノリスです。技術的負債の蓄積により保守・拡張コストが増大していたことから、現在、全社的な基幹システムリプレイスプロジェクトが進行しています。
このリプレイスでは、重要度と移行コストの両面を考慮した上で優先度をつけ、モノリスからの段階的な移行を進めています。リプレイスプロジェクトの背景や先行事例については「モノリスからマイクロサービスへ─ZOZOBASEを支える発送システムリプレイスの取り組み」で詳しく紹介しています。
最新の基幹システムリプレイスの状況については「巨大モノリスのリプレイス──機能整理とハイブリッドアーキテクチャで挑んだ再構築戦略」の発表資料にまとめています。発表の様子は「アーキテクチャConference 2025 協賛&参加レポート」で紹介しています。
会計システムの概要
私たちが取り組んでいる会計システムリプレイスは、発送システムと同様に基幹システムから独立したマイクロサービスとして新規に構築しています。
会計システムが扱うドメインの中核は、「弊社システムの売上実績のデータ」と「決済代行会社などの外部システムの入金実績のデータ」を突合する処理です。
会計用語でいう「入金の消込」にあたります。売上と入金の明細は各々任意のタイミングで到着します。その都度、決済ID単位で明細を照合し消込処理を実行する必要があります。
本記事のスコープと想定読者
このシステムのアーキテクチャとして、CQRS+ESを採用しました。本記事ではCQRS+ESの採用理由にも軽く触れますが、本題はAggregateの整合性境界と業務出力のスコープが一致しない場合に生じる設計課題と、その解法です。具体的には、数万件規模のデータをどのようにQuery側に届けるかという問題を扱います。
想定読者はCQRS+ESの基本的な概念を理解している方です。何らかのCQRS+ESフレームワークに触れたことがある方は、より興味深く読んでいただけます。
なぜCQRS+ESを選んだか
会計システムでは、すべての業務操作の履歴を厳密に記録し、後から追跡可能にすることが求められます。Event Sourcingでは、ビジネスエンティティの状態を「状態変更イベントの列」として永続化します。そのため、業務イベントの履歴がそのまま監査ログとして機能するという性質が、会計ドメインの要件と合致しました。
ここで重要なのは、ログとイベントの違いです。ログを記録するだけでは、ログと実際のシステムの動作が整合している保証はありません。一方、ESではイベント(事実)がすべての起点であり、イベントと動作が必ず整合します。会計システムにおいて「何が起きたか」を正確に追跡できることは、監査の観点から本質的な要件です。そのため、ESの採用が適切であると判断しました。
また、Queryの都合を気にしてドメインモデルを構築すると、最も重要なCommand側のロジック管理が複雑化します。CQRSによりCommandとQueryのモデルを分離することで、それぞれの関心事に集中した設計が可能になります。
社内の技術スタックをJavaに統一しており、Java上でCQRS+ESを実現するフレームワークとしてAxon Frameworkを採用しました。Axon Frameworkを選定した理由の1つは、CQRS+ESの実践に必要なプラクティスがフレームワークレベルで用意されている点です。具体的には、以下のような仕組みがフレームワークとして提供されています。
- イベントの永続化とリプレイ
- スナップショットによる集約の復元最適化
- Sagaによる複数集約の協調
- Processing Groupとセグメントによる並列処理の制御
これらを自前で実装する必要がないことで、CQRS+ESの基盤構築ではなく、ドメインの設計に集中できると判断しました。
インフラ構成の選択 ── RDB 1つでCQRS+ESを実現する
一般的なCQRSアーキテクチャでは、Command側とQuery側を別々のデータストアに分離し、メッセージブローカーを介してイベントを伝達する構成が採用されます。下図は、Axon公式ドキュメントに示されている一般的なCQRSアプリケーションの技術概要を参考に再作成したものです1。

公式図では、Event Store・Event Bus・Query側のデータベースがそれぞれ独立したコンポーネントとして描かれています。これらのインフラ構成には複数の選択肢があります。たとえばイベントストアとメッセージルーティングを一体で提供するAxon Serverや、Event BusにKafkaなどのメッセージブローカーを採用する構成が考えられます。
私たちのシステムではESの主な採用動機が監査ログの実現であり、高いスケーラビリティや外部システムへのイベント連携は要件ではありませんでした。そのため、これらの選択肢を以下の2つの観点から評価した結果、いずれも採用を見送りました。
- 金銭的コスト:Axon Serverのクラスタ構成のライセンス費用や、メッセージブローカーの追加インフラコストが発生する
- 学習コスト:チームにとってなじみの薄い技術スタックを導入した場合、学習コストと運用負荷が高くなる
チームに知見のあるRDBのみの構成でも要件を満たせることがわかり、Event Store・Event Bus・Read Modelをすべて単一のRDB上で実現する構成を採用しました。下図は、今回採用した単一RDB構成を示しています。

今回の構成では、独立したEvent Busコンポーネントは存在しません。Axon FrameworkがEvent Store(domain_event_entryテーブル)をポーリングすることで、Event Busの役割を実現しています。また、RDB上でのパフォーマンスを確保するために、Axon公式のRDBMSチューニングガイドを参考にインデックス設定等のチューニングを行っています。
私たちの構成では、同一データベース内にCommand側テーブル、Query側テーブル、そして共有テーブルが同居しています。Command側のテーブル(domain_event_entryやtoken_entry等)はAxon Frameworkが内部的に利用するテーブルであり、フレームワークが必要とするスキーマをそのまま作成しています。Query側のテーブルはRead Modelを表すrm_プレフィックスで管理しています。共有テーブルは標準構成ではなく私たちが独自に導入したものであるため、図中では点線で表記しています。詳細は次章以降で説明しますが、この「すべてが同一データベース内に存在する」という構成が、共有テーブルパターンの前提条件として重要な役割を果たします。
集約の境界と業務出力のスコープの不一致
小さな集約と大きな出力
私たちのシステムでは、Aggregate(集約)を小さな単位で保つ設計を採用しています。Vaughn Vernon氏は「Effective Aggregate Design」の中で、集約の設計について以下のように述べています。
Limit the Aggregate to just the Root Entity and a minimal number of attributes and/or Value-typed properties. (...) A large-cluster Aggregate will never perform or scale well.
(日本語訳)集約はルートエンティティと最小限の属性やValue型プロパティに限定すべきである。(中略)大きなクラスタの集約は、パフォーマンスもスケーラビリティも決して良くならない。
── Vaughn Vernon, "Effective Aggregate Design Part I"
この指針に従い、私たちのシステムでも集約を小さな単位で保っています。「背景」で述べた通り、売上と入金の明細を決済ID単位で照合するため、各集約も同じ粒度で設計しており、毎日膨大な数の集約インスタンスが生まれます。
決済ID単位の小さな集約にする必然性は、各明細が自身の状態に基づいて独立した判断・振る舞いを行う必要があるためです。各集約は消込に関するステータスを内部に保持しています。さらに、各明細に対しては削除コマンドを受け付ける要件があります。削除コマンドを受けた際、その明細がすでに帳票出力済みであれば打ち消しの帳票を出力してから削除するといった、明細単位の状態(消込ステータス、帳票出力済/未済等)に応じた振る舞いの分岐が求められます。このように、個々の明細が自身の状態に基づいて独立して判断する必要があるため、小さな集約としての設計が必然です。
一方で、帳票出力という業務処理は、これら数万件規模の集約を横断する大きなスコープで実行されます。帳票出力時には数万件規模の集約のステータスを「出力済」に更新し、さらにQuery側(Read Model)では、ステータスが更新された数万件規模のデータをもれなく帳票として出力する必要があります。
スコープの不一致が生む課題
下図は、この「スコープの不一致」を示しています。各集約は決済ID単位の小さな境界を持っていますが、帳票出力のスコープは数万件規模の集約を横断します。

1つの集約のスコープと業務出力のスコープには大きなギャップが存在します。この構造は、小さな集約という設計が正しいからこそ生まれる問題です。集約を大きくすれば解消できますが、それはVernon氏が指摘する「大きな集約のアンチパターン」に陥ることを意味します。したがって、集約の境界はそのまま維持した上で、数万件規模の集約を横断的に協調させる仕組みが必要になります。
Sagaで複数集約を協調させる
Sagaによる協調の構成
前章で示した「数万件規模の集約を横断的に協調させる」という課題に対して、Sagaを採用しました。Sagaは、複数のローカルトランザクションを協調させるパターンです2。
私たちの構成では、Sagaが数万件規模の集約にCommandを送信し、各集約が処理完了後にEventを返却し、Sagaがそれらを収集して全体の完了を判断します。実際にはSagaを親子に階層化し、親Sagaが子Sagaを複数起動して、子Sagaがバッチ単位で集約を管理する構成を採用しています。これにより、並列処理の流量制御も実現しています。下図は、この協調フローの概念を示しています。

子Sagaは各集約からの完了イベントを受け取るたびに処理済みの件数をカウントし、すべての集約の処理が完了した時点で親Sagaに完了を通知します。なお、集約が別のユースケースで削除済み、またはすでに帳票出力済みであった場合は、帳票出力の対象外であることを示すイベントを返却します。Sagaはこのイベントも処理済みとしてカウントし、帳票には出力しないものとして扱います。親Sagaはすべての子Sagaの完了をもって「全体完了」と判断します。数万件規模の集約を横断的に協調させるという課題自体は、このSagaの階層構造で解決できます。
協調の次に来る問題:Query側へのデータ伝達
Sagaが「全集約の処理が完了した」と判断した次のステップで、新たな問題が生まれます。数万件規模の処理結果を、Query側にどのように届ければよいのでしょうか。
Query側へのデータ伝達 ── イベントに載せきれないとき
ベストプラクティス:イベントに全情報を載せてQuery側に渡す
CQRS+ESにおけるベストプラクティスは、イベントに必要な情報をすべて載せてQuery側に渡すことです。
MicrosoftのCQRS Patternガイドでは、Command側とQuery側の同期について次のように述べています。
When you use separate data stores, you must ensure that both remain synchronized. A common pattern is to have the write model publish events when it updates the database, which the read model uses to refresh its data.
(日本語訳)別々のデータストアを使用する場合、両方の同期を保つ必要があります。一般的なパターンは、書き込みモデルがデータベースを更新する際にイベントを発行し、読み取りモデルがそのイベントを使用してデータを更新するというものです。
── Microsoft Azure Architecture Center, "CQRS Pattern"
イベントがすべての情報を運ぶことにより、Query側はCommand側のデータストアを直接参照する必要がなくなります。この「イベントを通じた疎結合」こそがCQRSの根幹です。Query側のProjection(イベントからRead Modelを導出する処理)は、受信したイベントのペイロードだけでRead Modelを構築できます。そのため、Command側とQuery側の独立性が保たれます。
数万件規模のデータをイベントに載せるべきか?
私たちのケースでこのベストプラクティスをそのまま適用できるでしょうか。前章で示した通り、Sagaが全集約の完了を検知した時点で数万件規模の処理結果をQuery側に届ける必要があります。ベストプラクティスに従えば、これらすべてのデータを完了イベントのペイロードに含めるべきです。
しかし、ここには2つの問題があります。1つ目はペイロードの肥大化です。数万件規模の集約に関するデータを1つのイベントに詰め込むことは、シリアライズ・デシリアライズのコストやメモリ使用量の観点から非効率です。2つ目はQuery側での利用形態との不一致です。帳票出力の後続処理では、前段のProjectionで構築済みのrm_テーブルとのJOINが必要です。仮にイベントペイロードにデータを収められたとしても、Query側で結局テーブルに展開してJOINすることになるため、イベント経由で運ぶ利点は薄れます。
採用したパターン:共有テーブル+シグナルイベント
先述の問題に対して、いくつかの方針を検討しました。
1つ目はQuery側のProjectionで完結させるアプローチです。各集約の処理完了イベントをProjectionが受信してrm_テーブルに書き込み、すべての書き込みが終わった後に帳票を出力する方式です。
しかし、数万件規模のイベントを実用的な時間内に処理するにはProjectionの並列化が必須です。Axon FrameworkのTracking Processorでは、複数のセグメントがイベントを分担して並列に処理します。同一セグメント内ではイベントの処理順序が保証されますが、完了イベント(シグナルイベント)と各集約の処理完了イベントは異なるセグメントに振り分けられうることが問題です。
異なるセグメント間では処理の進行度が異なるため、あるセグメントが完了イベントを処理した時点で、別セグメントではまだ処理が完了していない可能性があります。
つまり、シグナルイベントがProjectionに届いた時点でrm_テーブルへの書き込みが完了していない可能性があり、データの欠損が生じます。これを防ぐにはProjectionに協調ロジックが必要ですが、それはSagaの責務であり、Projectionの関心事の分離を崩すため、見送りました。
2つ目はイベントの分割送信(チャンク化)です。数万件のデータをN件ずつ複数のイベントに分割して送信する方式です。しかし、この方式ではQuery側のProjectionが「すべてのチャンクが届いたか」を判定する協調ロジックを持つ必要があり、1つ目と同じ問題構造を抱えるため、見送りました。
3つ目はClaim Checkパターンです。イベントにはデータ本体を載せず、外部ストレージへの参照のみを含める方式です。技術的には実現可能ですが、以下の理由から見送りました。
- 外部ストレージの導入は「インフラ構成の選択」で述べた単一RDB構成の方針を崩す
- 外部ストレージへの書き込みはEvent Storeと別トランザクションになり、障害時の整合性担保が複雑化する
これらの検討を経て、私たちは単一RDB構成の利点を活かした共有テーブルとシグナルイベントを組み合わせたパターンを採用しました。前述の通り、個々の明細データは通常のProjectionでRead Modelに構築済みです。不足しているのは、どの明細がどの帳票に属するかという対応関係です。このパターンの構成は以下の通りです。
- Sagaは帳票出力フローの開始時に帳票IDを採番し、各集約にCommandを送信する。処理完了イベントを受信するたびに、同一トランザクション内で帳票IDと明細IDの対応関係を共有テーブルに逐次書き込む
- すべての集約の処理が完了したら、Sagaは完了イベントを発行する(ペイロードは最小限のシグナルのみ)
- Query側のProjectionは完了イベントをトリガーとして受信し、帳票出力が可能になったことを示すRead Model(
rm_テーブル)を作成する - 後続のレポート生成処理がこのRead Modelを検知し、帳票のRead Model・共有テーブル・明細のRead Modelを順にJOINして帳票データを取得する

ステップ1のポイントは、Axon FrameworkのSagaがイベントハンドラの処理をUnit of Work(UoW)パターンで管理している点です。イベントの受信と共有テーブルへの書き込みが同じトランザクションで実行されるため、すべての集約の処理が完了した時点では、対応するデータが共有テーブル上にも確実にそろっています。
ここで重要なのは、「インフラ構成の選択」で説明した単一RDB構成です。Command側テーブル、Query側テーブル、そして共有テーブルがすべて同一のデータベース内に存在するため、共有テーブルへの書き込みとJOINによる読み取りが自然に実現できます。もしCommand側とQuery側が異なるデータストアに分離されていたら、このパターンは成立しません。
先述のProjection完結アプローチで問題となったセグメント間の進行度の差は、本パターンでは構造的に発生しません。共有テーブルへの書き込みをSagaが担い、すべての書き込みが完了した後に初めて完了イベントを発行するためです。
このパターンの解釈
このパターンでは文字通りCommand側とQuery側でテーブルを共有しています。これはCQRSの原則「Command側とQuery側はイベントを通じてのみ情報をやり取りする」からの意図的な逸脱です。将来的なデータストアの物理分離が難しくなるトレードオフはありますが、以下の2点を考慮し採用しました。
- 現時点でCommand側とQuery側の物理分離は想定されないこと
- 共有テーブルは明示的に設計・管理されており、暗黙の依存ではないこと。将来的に物理分離が必要になった場合も、共有テーブルの参照箇所が明確であるため、段階的な移行が可能であること
実際にこの設計で運用してみて、Projectionのロジックがシンプルに保たれ、Event Storeのペイロード肥大化も回避できている点に手応えを感じています。一方で、共有テーブルのスキーマ変更がCommand側とQuery側の両方に影響する点には注意が必要です。通常のCQRSでは、Command側とQuery側のスキーマを独立に変更できることが利点の1つですが、共有テーブルに関してはこの利点が失われます。
CQRS+ESを実践してみて
本記事で紹介したSagaによる数万件規模の集約の協調は、Axon FrameworkのSagaサポートがなければ実現が困難でした。その場合、Sagaの状態管理やイベントとの紐付けといった基盤部分の実装から始める必要がありました。同様に、スナップショットによる集約の復元最適化やProjectionの進捗管理(Tracking Processor)も、自前で実装していたら多大な工数を費やしていたと考えられます。前述したこれらの基盤が揃っていたからこそ、アーキテクチャレベルの設計課題に対して検討と試行錯誤の時間を確保できました。
加えて、Axon FrameworkでESを実現する中で、集約内部のロジックが関数的な構造になる点にも良さを感じています。集約のCommand Handlerは、Commandを受け取ってEventを発行し、Event Sourcing Handlerは、Eventを受け取って集約の状態を更新します。テストも、Axon Frameworkが提供するテストフィクスチャを用いて「Given(過去のイベント列)→ When(コマンド)→ Then(期待されるイベント)」という宣言的な形式で記述できます。この構造は、AIによるテスト駆動開発と相性が良いと感じています。入力と出力が明確に定義されているため、AIがテストケースを生成しやすく、またテストの意図が宣言的に表現されるため、AIが生成したテストコードのレビューもしやすいという実感があります。
一方で、ESを本格的に運用する難しさも実感しています。ESではすべての状態変更が「コマンド → 集約 → イベント」のパイプラインを通ります。ステートソーシングであれば一括更新で済む処理も、集約ごとにコマンドを送信し、個別にイベントを発行しなければなりません。本記事で扱った集約横断の協調は、まさにこの制約から生まれた設計課題です。
この課題に関連して、近年提唱されているDynamic Consistency Boundary(DCB)という概念に注目しています。DCBは、一貫性の境界を集約に固定せず、イベントへ付与するタグに基づいて動的に伸縮させるアプローチです。従来のESでは集約の境界が設計時に固定されるため、本記事で扱ったようなSagaによる協調が避けられませんでしたが、DCBによってこの複雑さを軽減できる可能性があります。私たちのユースケースにどこまで適用できるかはまだ未知数ですが、ESの実践的な課題を構造的に解決しうるアプローチとして、今後の動向を追っています。
まとめ
本記事では、会計システムへのCQRS+ES適用において、小さな集約を保ちながら大量の集約をまたいだ業務出力を実現する過程で得られた知見を紹介しました。
小さな集約を正しく設計するほど、業務出力のスコープとの不一致が顕在化します。Sagaで数万件規模の集約を協調させることはできますが、その結果をQuery側に届ける段階で「イベントに載せきれない」という壁にぶつかりました。共有テーブルとシグナルイベントを組み合わせたパターンを採用し、CQRSの原則からは逸脱しつつも、実用的な解決策にたどり着きました。
CQRS+ESの実装事例はまだ多くなく、今回の実装についても正しいものであるかという不安と向き合いながら進めてきました。リリースしてみて大きな問題は発生しておらず、ポジティブな状況であると捉えています。しかし、ベストプラクティスがさらに確立されてきた際には、それに適応していく姿勢を持ち続けたいと考えています。
本記事では会計領域のリプレイスを紹介しましたが、同じ基幹システムリプレイスの物流領域でもメンバーを募集しています。大規模モノリスからのサービス分割に取り組むポジションで、ドメイン駆動設計やイベント駆動アーキテクチャの知識を活かせる環境です。物流システムの刷新に興味のある方は、ぜひご覧ください。
さらにZOZOでは、一緒にサービスを作り上げてくれる仲間を広く募集中です。ご興味のある方は、以下のリンクからぜひご覧ください。
- この図はAxon公式ドキュメント「Architecture Overview」の図を参考に、本記事で必要な構成要素に絞って再作成したものです。↩
-
厳密には、SagaとProcess Managerは異なる概念です。Sagaは補償トランザクションに焦点を当てたパターンであるのに対し、Process Managerは状態マシンとしてモデリングされ、受信イベントと現在の状態に基づいて判断を下します。Axon Frameworkでは
@Sagaアノテーションを使用して、Orchestration方式のProcess Managerを実装しています。本記事では、フレームワークの慣例に合わせて「Saga」と表記します。↩