ひとことで言うと#
ドメインオブジェクトを「一貫性を保つ単位(集約)」にまとめ、トランザクションの境界を明確にする設計手法。データベースの都合ではなくビジネスルールの都合でまとめ方を決めるのがポイント。
押さえておきたい用語#
- Aggregate(アグリゲート)
- 関連するオブジェクトを束ねて一貫性の境界を形成するまとまり。1つのトランザクションで整合性を保証する単位になる。
- Aggregate Root(アグリゲートルート)
- 集約の入り口となるルートエンティティ。外部から集約内部のオブジェクトに直接アクセスせず、必ずルート経由で操作する。
- Entity(エンティティ)
- 同一性(ID)で区別されるオブジェクト。注文や顧客のように、属性が変わっても「同じもの」として追跡する必要があるもの。
- Value Object(バリューオブジェクト)
- 属性の値だけで等価性が決まるオブジェクト。金額や住所のように、同じ値なら交換可能である。
- Invariant(不変条件)
- 集約が常に満たすべきビジネスルールを指す。「注文合計が在庫数を超えない」などの制約がこれにあたる。
集約設計の全体像#
こんな悩みに効く#
- エンティティ間の関係が複雑でどこまでを一つのトランザクションにすべきかわからない
- マイクロサービスの分割単位が大きすぎたり小さすぎたりして困っている
- データの不整合が本番環境で頻発している
基本の使い方#
「ビジネスルールとして絶対に壊れてはいけない制約」を列挙する。技術的な制約ではなく、ドメインエキスパートが「これが崩れたらビジネスが成り立たない」と言うものが対象。
- 「注文の合計金額は明細の合計と一致しなければならない」
- 「在庫数は0未満にならない」
- 「予約枠は定員を超えてはならない」
同じトランザクション内で守らなければならない不変条件を共有するオブジェクトを1つの集約にまとめる。集約はなるべく小さくする。
- 注文(Order)と注文明細(OrderLine)は同じ集約
- 商品(Product)は別の集約(注文時に在庫の参照はするが、同一トランザクションで更新する必要はない)
集約の中で最も外部との接点が多いエンティティをルートにする。ルートはIDでグローバルに一意であり、集約外部はルートのIDだけを保持する。
- Order集約のルートはOrderエンティティ
- OrderLineへは必ずOrder経由でアクセスする
集約同士はオブジェクト参照ではなくIDで参照する。これにより集約間の結合が疎になり、マイクロサービスへの分割も容易になる。
- OrderはProduct集約をオブジェクト参照で持たず、
productIdで参照 - 結果整合性(Eventually Consistent)で集約間の整合性を担保する
具体例#
従業員40名のECスタートアップ。注文処理で在庫不整合が月に12件発生し、手動修正に月20時間を費やしていた。
不変条件の整理
| 不変条件 | 対象オブジェクト |
|---|---|
| 注文合計 = 明細合計 | Order, OrderLine |
| 明細の数量 > 0 | OrderLine |
| 割引率は100%を超えない | Order, Discount |
| 在庫数 >= 0 | Product(別集約) |
集約の定義
- Order集約: Order(ルート)、OrderLine、Discount
- Product集約: Product(ルート)、StockQuantity
注文確定時にProduct集約の在庫を確認するが、同一トランザクションでは更新しない。在庫引き当てはドメインイベント OrderPlaced を発行して非同期で処理する。
導入後、在庫不整合は月12件から0件に減り、手動修正の20時間がそのまま開発に回せるようになった。
月間予約数15万件の予約管理SaaS。繁忙期にダブルブッキングが発生し、利用店舗から毎月30件以上のクレームが来ていた。
問題の根本原因: 予約スロットと店舗情報を1つの巨大エンティティで管理しており、予約のたびに店舗テーブル全体をロックしていた。ロック競合でタイムアウトが頻発し、リトライ時にダブルブッキングが起きていた。
集約の再設計
- TimeSlot集約: TimeSlot(ルート)、Capacity。不変条件は「予約数 <= 定員」
- Reservation集約: Reservation(ルート)、Guest。不変条件は「1予約に1人以上のゲスト」
- Shop集約: Shop(ルート)、BusinessHours
TimeSlotを独立した小さな集約にしたことで、ロック範囲が「特定の時間枠」だけに縮小。予約処理のスループットが 4倍 に改善し、ダブルブッキングはゼロになった。
1日5,000件の配送を扱う物流企業。配送ステータス更新のAPIレスポンスが平均2秒を超え、ドライバー用アプリの操作性が深刻に悪化していた。
原因を調べると、Shipment集約が配送先、荷物明細、追跡履歴、ドライバー割り当て、請求情報を全部抱え込んでおり、ステータス更新のたびに数百行のデータをロードしていた。
再設計後の集約構成
| 集約 | ルート | 含まれるオブジェクト | 主な不変条件 |
|---|---|---|---|
| Shipment | Shipment | Package, Destination | 荷物重量合計 <= 車両積載量 |
| Tracking | TrackingEvent | Location, Timestamp | イベントは時系列順 |
| Billing | Invoice | InvoiceLine | 請求額 = 明細合計 |
集約を3つに分離した結果、ステータス更新のAPIレスポンスは 2秒 → 180ms に短縮。ドライバーからの「アプリが重い」という問い合わせもほぼなくなった。
やりがちな失敗パターン#
- 集約を大きくしすぎる — 関連するものをすべて1つの集約に入れると、ロック競合やパフォーマンス劣化が起きる。「同じトランザクションで守るべき不変条件」だけを基準にする
- データベースのテーブル構造から集約を決めてしまう — テーブルのJOIN関係と集約の境界は別物。ビジネスルール(不変条件)から決めるのが正しい順序
- 集約間をオブジェクト参照でつないでしまう — 集約間はIDで参照する。オブジェクト参照にすると境界が曖昧になり、トランザクションスコープが意図せず広がる
- 結果整合性を恐れて全部を即時整合にする — 即時整合が必要なのは集約内だけ。集約間は結果整合性で十分なケースがほとんどで、ドメインイベントを使えば自然に実現できる
まとめ#
集約設計は 「何を1つのトランザクションで守るか」 をビジネスルールから決める手法。まず不変条件を洗い出し、それを共有するオブジェクト群を小さくまとめるのが基本。集約間はIDで疎結合に保ち、結果整合性を受け入れると、スケーラビリティとデータ整合性を両立できる。最初は小さく始めて、不変条件の追加に応じて境界を調整していくのが現実的な進め方になる。