今さら聞けないDDDとEvent Sourcingにおける集約
こんにちは、Dress Code でプロダクト開発に奮闘中のかわうそです。
今回は、DDD(ドメイン駆動設計)や Event Sourcing における集約について整理してみます。
Dress Code でも DDD や Event Sourcing を採用しているのですが、日頃の会話で”集約”というワードを使いながらも適切に使えているのか怪しいときがあるなぁと思い始めたので、整理がてら記事にしてみます。
DDD における集約ってなんだ?
AI の回答
ドメイン内のエンティティ(Entity)と値オブジェクト(Value Object)のクラスター(グループ)で、ビジネスルールに基づいて一貫性を保つための単位です。これにより、ドメインの整合性を守りながら、システムの複雑さを管理します。
トランザクションの境界を定義し、外部からのアクセスを制限することで、データの不整合を防ぎます。集約は「原子的な変更単位」として扱われ、部分的な更新ではなく全体として扱います。
Dress Code では従業員、契約といった概念などを集約として定義しており、ドメインの整合性を守るために利用しています。
BtoB SaaS におけるデータは、さまざまな業務で利用されるデータになるので、ドメインやデータを守るという観点で、DDD の集約はとても重要な概念になります。
例えば、従業員だと以下のようになります。
(あくまで例です、Worker という集約はめちゃでかくなりますので注意が必要です笑)
// 集約
export namespace Worker {
// 集約ルート
export class WorkerEntity {
readonly id: WorkerId
readonly workLocation: WorkLocationEntity
constructor(...) {
// 集約を生成する際のValidationやビジネスロジック
}
...
}
// Entity
class WorkLocationEntity {
id: WorkLocationId
address: Address
...
}
// Value Object
class Address {
...
}
}
”集約”におけるポイントは集約を「原子的な変更単位」として扱うことです。
DDD における集約は集約ルート (Aggregate Root)と内部オブジェクト(Entity や Value Object)で構成されます。集約ルート (Aggregate Root)は集約のエントリポイントとなる Entity で、外部からはこのルート経由でしか内部のオブジェクトにアクセスできないようにします。
上記の例の場合、WorkLocationEntity は内部オブジェクトに該当するので export(public)を許可しないようにするということになります。
また、永続化のために Repository を定義した場合は以下のようになります。
interface WorkerRepository {
create(worker: Worker.WorkerEntity): Promise<Worker.WorkerEntity>;
}
これに対して、部分的な更新を許容してしまうと、以下のような Repository 定義が生まれてしまうことがあります。
(今までにも Entity と Repository を 1 対 1 で構成してしまうパターンはよくやってました笑)
// MethodにEntityを渡すパターン
interface WorkerRepository {
create(worker: Worker.WorkerEntity): Promise<Worker.WorkerEntity>;
createWorkLocation(
worker: Worker.WorkLocationEntity
): Promise<Worker.WorkLocationEntity>;
}
// Repositoryごと分けてしまうパターン
interface WorkLocationRepository {
create(worker: Worker.WorkLocationEntity): Promise<Worker.WorkLocationEntity>;
}
集約の目的はデータの不整合を防ぐことになるので、上記のようなパターンを実装してしまうと、集約ルート(WorkerEntity)のビジネスルールを適用せずに Entity(WorkLocationEntity)を永続化できてしまうため、容易にデータの不整合を生むことができます。
なぜ、「原子的な変更単位」として扱うことでデータ不整合を回避できるのか
それは必ず集約を生成するというプロセスを強制させることにあると思います。
上記の例だと WorkerRepository の create は WorkerEntity を要求しているので、永続化するタイミング(ユースケースから呼び出す時など)で必ず WorkerEntity を生成する必要があります。
WorkerEntity に適切な Validation(ビジネスロジック)が実装されていれば、不整合のある集約が生成されることはありません。
(適切な Validation ってなんやねんはひとまず置いておいて笑)
仮に、部分的な更新を許容してしまった場合、集約を経由せずとも、データの永続化が可能になってしまいます。
例えば、上記の例だと、WorkLocationRepository の create は WorkLocationEntity を要求しているので、永続化するタイミングで必要なのは WorkLocationEntity のみを生成すれば可能です。
これを実装してしまうと、集約ルート(WorkerEntity)にあるビジネスロジックやルールは通過することができなくなり、また、集約ルート(WorkerEntity)と Entity(WorkLocationEntity)との関係性の間にある制約なども守ることが難しくなります。
余談:制約を課すということで AI によるコード生成の精度を上げる
「原子的な変更単位」として扱い、部分的な変更を許容しないというような制約があることで、AI のコード生成の精度が上がると考えています。
自由な状態で AI に実装を指示するよりも、制約を課した状態で指示することで、不整合なデータができそうな実装が生まれたり、今まで見たことのないパターンの実装が生まれたりすることは起こりにくくなります。
(勝手に集約分けたりすんなや〜とかはありますが笑)
DDD における集約の特徴とルール
改めて、DDD における集約の特徴とルールは以下になります(AI の回答)
- 一貫性: 即時一貫性(Immediate Consistency)を保証。集約内の変更はトランザクション内で原子的に行われ、ビジネス不変条件(Invariant)を常に満たします。
- 境界の定義: 境界付きコンテキスト(Bounded Context)内で定義され、他の集約とはリファレンス(参照)で連携。直接的なオブジェクト参照は避け、整合性を崩さないようにします。
- 永続化: リポジトリ(Repository)パターンを使って集約ルート単位で保存・取得。データベースでは集約全体を一つのトランザクションで扱います。
- 設計のポイント:
- 集約のサイズは小さく保つ(大きすぎるとパフォーマンス低下の原因)。
- ドメインエキスパートと協力して、ビジネスルールに基づいて境界を決める。
DDD における集約の難しさ
集約を実装する上でのポイントが 集約のサイズを小さく保つこと です。
集約が大きすぎると 1 つのトランザクションが大きくなり、パフォーマンスが低下してしまいます。
ただし、集約を分けるということは、トランザクションの境界を分けるということになるので、安易に集約を分けてしまうと、思わぬデータ不整合に繋がったり、想定していないところでプロダクトに影響することがあります。
なので、ビジネスルールに基づいて境界を決めることが重要とされていますが、ここが集約のとても難しいところだと思います。
たしかに、業務想定やユースケースなどから境界を分けることはできますが、業務上、どうしても同一集約(トランザクション)で取り扱いたいこともあるし、ロールバック的なことを考えると同一集約(トランザクション)にまとめてしまった方が、断然楽ではあります。
とはいえ、ドメインの複雑さを整理し、スケーラビリティを向上させるためにも、集約を適切に分けていく活動が必要になります。
集約が大きすぎれば、その集約の配下に必要な Entity や ValueObject は多くなってしまうし、集約に対するビジネスロジック等も肥大化していき、むしろドメインは複雑化する可能性もあります。
また、集約が大きすぎると、トランザクションも大きくなってしまうので、スケーラビリティも低下してしまいます。
集約のサイズが大きいことは悪なのか?
これはあくまで個人的な意見ですが、集約のサイズが大きいことは悪ではないと考えています。
近年、クラウドやデータベースが進化しており、TiDB のような NewSQL も普及してきました。今までボトルネックであった書き込みのスケーラビリティも断然確保しやすくなったように感じています。上記で感じていたスケーラビリティの課題も、実装ではなく外部リソースを利用して向上させるということも可能だったりもします。
また、集約が大きいということは必要なビジネスロジックやルールが 1 つの実装(ソースコード)に集約されているということになります。
つまり、人間や AI が読むコンテキストを分散させずに済むということになり、むしろ AI に実装してもらうときは効率よく開発できるのではないかとも考えています。
現代では、無理して集約を小さく保つよりは、集約を適切に実装することを優先して、集約の大きさ、スケーラビリティ、AI の実装具合を見ながら、集約を実装していくのが良いかなとか考えています。
(要はバランス ∑(゚ Д ゚))
Event Sourcing における集約ってなんだ?
AI の解答
イベントのストリーム(シーケンス)を基に状態を動的に再構築する単位。集約は、コマンド(Command)を受け取って新しいイベントを生成し、それらを適用することで状態を更新します。状態はイベントの蓄積から導出され、直接保存されません。
システムの変更履歴を完全に保持し、監査性や再現性を高めます。DDD の集約と組み合わせることで、ビジネスドメインの複雑さをイベントベースで扱います。
Dress Code では契約や組織改編といった概念などを集約として定義して、Event Sourcing を活用しています。
BtoB SaaS において、業務上で扱うデータの変更履歴(主にどの時点でどういうデータであったかという履歴)を残すということが必要になるケースは多く存在します。
例えば、契約だと以下のようになります。
(Worker の集約と同様に Contract という集約はめちゃでかくなりますので注意が必要です笑)
// 集約
export namespace Contract {
// 集約ルート
export class ContractEntity {
...
}
// コマンド(CQRSの文脈もふれるためコマンドも例として挙げてます)
export namespace ContractCommands {
// 雇用契約手続きによる契約の作成コマンド
export class CreateEmploymentContractCommand {
...
}
}
// イベント
export namespace ContractEvents {
// 雇用契約手続きにより契約が作成されたイベント
export class EmploymentContractCreatedEvent {
...
}
}
}
Event Sourcing における集約は、集約ルート (Aggregate Root)とイベントで構成されます。
集約ルート (Aggregate Root)は集約のエントリポイントとなるオブジェクトで、イベントをリプレイ(再生)することで現在の状態を構築し、コマンド処理時に新しいイベントを生成します。
また、イベントは状態変更の事実を表す不変オブジェクトで、Append-Only(追加専用)で保存され、集約のライフサイクルを形成します。
Event Sourcing における集約はシステムの変更履歴を完全に保持することができるので、これが一番のポイントだと思います。
全体的なフローとサンプルの実装を踏まえて少しだけ解説します。
// 集約ルート
export class ContractEntity {
...
// 集約に対して変更(コマンドを適用)
static applyCreateEmploymentContractCommand(
command: ContractCommands.CreateEmploymentContractCommand
): Result<ContractEvents.EmploymentContractCreatedEvent, ContractEntityError> {
// Validation(Event Sourcingの文脈のみだとビジネスロジックは持たないが、ここではあくまで例です)
const result = this.validateCreateEmploymentContractCommand(command);
if (result.isErr()) return err(result.error);
// Event Generation
return ok(
EmploymentContractCreatedEvent.create({
...
})
);
}
// イベントから集約を復元
static fromEvents(events: ContractEvents[]): ContractEntity {
// イベントを適用
const entity = events.reduce(
(entity, event) => entity.applyEvent(event),
new ContractEntity(...)
);
}
// イベントを適用
applyEvent(event: ContractEvents): ContractEntity {
switch (event.eventType) {
case ContractEvents.EmploymentContractCreatedEvent:
return this.applyEmploymentContractCreatedEvent(event);
...
}
}
// スナップショットから集約を復元
static fromSnapshot(snapshot: Record<string, unknown>): ContractEntity {
...
}
...
}
Temporal Data Model は利用しないの?
変更履歴を残すという部分において、Event Sourcing だけでなく Temporal Data Model を活用することで部分的に実現ができます。
Dress Code でも Temporal Data Model を活用している部分はありますが、Temporal Data Model の場合、いつからいつまでどういうデータであったかは残すことが可能ですが、どこからなぜそのようになったのかを残すには別の仕組みが必要になります。
また、Temporal Data Model では有効期間や トランザクション の記録を持つ必要があり、データの取得や更新が複雑になることがあります(特定時点で有効であったレコードを取得したり更新したりする必要があるケースなど)
なので、Dress Code で複雑な履歴データでかつ、監査的な側面を重視しているデータは Event Sourcing を利用して履歴を残すようにしています。
(ぶっちゃけですが、Temporal Data Model よりも Event Sourcing の方が複雑度が低くなる感覚があり、確実にデータを残すという意味でも、Event Sourcing に寄せたい気持ちもありますね〜笑)
Event Sourcing における集約の特徴とルール
改めて Event Sourcing における集約の特徴とルールは以下になります(AI の回答)
- 状態の扱い: 伝統的な状態保存(State Persistence)ではなく、イベント保存(Event Persistence)。現在の状態はイベントを順番に適用してハイドレート(復元)します。パフォーマンス向上のため、スナップショット(状態のキャッシュ)を導入する場合もあります。
- 一貫性: 最終的一貫性(Eventual Consistency)を採用。イベントは原子的に追加され、他の集約との連携はイベントバスや Saga パターンで非同期に処理します。
- 永続化: イベントストア(例: EventStoreDB、Apache Kafka)を使ってイベントを保存。リポジトリはイベントを読み込んで集約を再構築し、新しいイベントを追加します。
- 設計のポイント:
- イベントの設計: 各イベントはドメインのビジネス事実を反映(例: 「OrderPlaced」ではなく「CustomerPlacedOrder」)。
- バージョン管理: イベントのスキーマ変更時はアップキャスト(Upcasting)で対応。
- CQRS との統合: 書き込み側(Command)で ES を使い、読み取り側(Query)で投影(Projection)を作成。
CQRS との関係性
CQRS(Command Query Responsibility Segregation)とは、Command(書き込み) と Query(読み取り)を分離することを目的としたデザインパターンです。
Event Sourcing は集約の状態をイベントとして保存し、コマンド処理で新しいイベントを生成することで、履歴の保持と再構築を可能にします。
つまり、Event Sourcing は CQRS における Command(書き込み)側の状態管理を担うことができる 1 つの手段ということです。
(Event Sourcing と CQRS もごちゃごちゃしやすいので注意が必要ですね笑)
DDD と Event Sourcing における集約の共通点と違い
大きな共通点が「集約ルートをエントリポイント」としていることです。
DDD にしろ、Event Sourcing にしろ、集約から変更を行うという制約があることで、ドメインやデータの整合性を守っています。
それ以外は状態管理や一貫性を含め違いがあります(以下にまとめます)
項目 | DDD の集約 (ES なし) | ES の集約 |
---|---|---|
状態管理 | 現在の状態を直接保存 | イベントで状態を再構築 |
一貫性 | 即時一貫性 (トランザクション内原子性) | 結果整合性 (非同期処理可能) |
永続化 | リポジトリで集約全体を保存 | イベントストアにイベントを追加 |
履歴管理 | 追加の実装が必要 | 自然に保持 (監査・デバッグが可能) |
複雑さ | シンプルだがスケールしにくい | 柔軟だがイベント設計・バージョン管理が必要 |
例 | Worker の情報を直接更新 | Worker の変更をイベントとして記録 |
DDD における集約と Event Sourcing における集約ってどういう関係性?
Event Sourcing における集約ではドメインモデルやビジネスロジックについては言及していません。あくまで、履歴が本質であり、状態をイベントのストリームとして保存し、リプレイを通じて動的に再構築するというのが Event Sourcing だと解釈しています。
なので、DDD の集約を Event Sourcing と組み合わせることで、DDD におけるビジネスルールを含めたドメインモデルをイベントのシーケンスとして永続化できるようになる、つまり、DDD の集約を Event Sourcing と組み合わせることで、監査性や柔軟性が向上させることができる関係性(補完関係)にあるということになります。
(Eric Evans の DDD 本でも Event Sourcing との相性について、こんな感じのことを言及していた気がする)
DDD における Domain Events とはどういう関係性?
DDD や CQRS の文脈だと Domain Events という概念が出てくるので、少しだけ触れておきます。
Domain Events の概要(AI の回答)
- 定義: Domain Events は、ドメイン内で「何か重要なことが起こった」ことを表す不変のオブジェクトです。ビジネスにとって意味のある事実(例: 「注文が確定した」「ユーザーが登録した」)をキャプチャします。
- 例: e コマースで OrderPlaced(注文確定)や CustomerRegistered(顧客登録完了)など。
- 役割: 集約間の疎結合な通信、状態変更の追跡、外部システムとの統合を促進。ドメインの意図を明確に表現し、イベント駆動アーキテクチャの基盤となります。
- 特徴:
- 過去形で命名(例: OrderPlaced ではなく OrderWasPlaced)して、発生した事実を強調。
- 集約内で生成され、外部に公開されることが多い。
- イベントストアやメッセージブローカー(例: Kafka、RabbitMQ)を介して伝播可能。
Domain Events とはドメイン内で発生する重要なビジネスイベントを表現します。
DDD における集約によりビジネスルールを守りながら状態を変更し、その結果として Domain Events が生成されます。DDD において集約の状態を変えること自体がビジネスルールだと考えられるので、その結果を 1つのイベント= Domain Events として扱うのは自然な形だと思います。
また、Event Sourcing における集約は、ビジネス事実というよりは低レベルなイベントが中心ですが、DDD における集約と組み合わせることで、集約によって状態変更した情報を Domain Events としてビジネス事実に基づいたイベントとして扱うことができます。
Event Sourcing 文脈だけではイベントにビジネス事実は存在しないが、DDD における集約と組み合わせることで、イベント自体をビジネス事実として扱うことができます。
(この辺の情報を実装ベースだけで考えるのが難しいので、イベントストーミングのような設計手法があると進めやすいですね)
まとめ
- DDD における集約とは
- ドメインのビジネスルールを反映した静的な構造
- Event Sourcing における集約とは
- イベントのシーケンスとして保存され、動的な履歴管理
-
DDD における集約と Event Sourcing における集約は補完関係
- ビジネスルールを満たす静的な構造をイベントのシーケンスとして動的に管理
- ビジネスロジックを保持しつつ、監査性や柔軟性が向上
- Event Sourcing によって発生するイベントを Domain Events として扱えるようにもなる
- Domain Events はドメイン内で発生する重要なビジネスイベントを表現
- DDD における集約によりビジネスルールを守りながら状態を変更し、その結果を Domain Events が生成
-
Event Sourcing は CQRS の書き込み(Command)側の状態管理を担う 1 つの手段
- Event Sourcing は書き込み(Command)側でイベントを生成し、イベントストアに保存
- 読み取り側(Query Side)は、イベントから投影(Projection)された専用ビューを参照
- 書き込みと読み取りの分離により、負荷分散やスケーラビリティが向上させることができる
おわりに
うーん、まだまだ、整理が足らんですわ笑
アクターモデル (Actor Model)とかも含めてもう一度整理したいですわね〜
Discussion