集約境界は強整合を要求する不変条件の所有者から引き直す

一つのユースケースに複数の集約が登場すること自体は普通である。ユースケースは利用者の目的を完了させる処理の範囲であり、集約は一度の変更で不変条件を守る範囲なので、両者の境界は一致しなくてよい。複数の集約を順に変更し、一時的な不整合を処理中や予約済みといった状態で表せるなら、集約間は結果整合でつなげられる。

集約境界を引き直すときは、「二つのものを同時に更新したい」という処理の形から考えず、コミット済みの状態として何を存在させてはいけないかを命題にする。たとえば「注文と在庫を同時に更新する」ではなく、「確保済みの数量は販売可能な数量を超えない」のように表す。その違反状態を一瞬でも公開できないなら、その命題を判定して状態を変更する主体を一つにする必要がある。

以下の各パターンでは、構造図で集約境界を示し、その直後の擬似コードで、ユースケースが読み書きする集約と保存単位を示す。図だけでは、一つの箱に見えるものが実際に一度の保存単位なのか、それとも複数集約を同じトランザクションで囲んだものなのかを区別できないためである。

再定義前の注文確定

注文集約が未確定と確定済みの状態を持ち、在庫集約が販売可能数と注文別の確保を持つとする。注文確定ユースケースでは、注文を確定済みにすると同時に、その注文に必要な在庫を確保する。ここで求める不変条件を「コミット済みの状態では、注文が確定済みであることと、その注文分の在庫が確保済みであることが同時に成立する」と定めるなら、片方だけを更新した状態は一瞬でも公開できない。

flowchart LR
    U[注文確定ユースケース] --> T
    subgraph T[単一データベーストランザクション]
        O[注文集約を確定済みにする]
        I[在庫集約で数量を確保する]
        O <-. 両方の成立を不可分にする .-> I
    end
function confirmOrder(orderId):
    transaction:
        order = orderRepository.find(orderId)
        inventory = inventoryRepository.findFor(order.lines)

        reservationId = inventory.reserve(order.id, order.lines)
        order.confirm(reservationId)

        inventoryRepository.save(inventory)
        orderRepository.save(order)

この前提なら、二つの集約を単一データベーストランザクションで更新する実装には理由がある。ただし、これは集約を再定義した状態ではない。注文集約と在庫集約を別の更新単位として残し、ユースケースのトランザクションを両者より広い強整合の境界として追加した状態である。

確認済みという事実を一つの集約へ移せず、関連する状態を一つの有限な集約へ収めることもできないなら、この構造を明示的に選ぶことになる。各集約は自身の局所的な不変条件を守り、注文確定ユースケースが集約間の不変条件を守る。代わりに、ロックの獲得順序、競合、トランザクションの長さ、二つの集約が同じデータベースに存在するという配置上の制約を設計の一部として引き受ける。

ここから集約を再定義する方法は、確認済みという事実の所有者を移す方法と、注文と在庫を一つの集約へ含める方法に分かれる。どちらも成立しない場合は、複数集約の単一データベーストランザクションを例外として受け入れることになる。

注文の確定状態を在庫引当に統合する

このパターンは、注文集約の確定済み状態を残したまま、在庫引当から非同期に複製する方法ではない。「注文が確定した」と「必要な在庫の引当が成立した」が同じ業務上の事実なら、二箇所にあった表現の片方を削除できる。注文集約は変更不能になった注文内容と確定要求までを所有し、在庫引当集約に注文識別子と数量の引当が追加されたことだけを、注文確定の正式な記録にする。

flowchart LR
    O[注文集約<br/>変更不能な注文内容] -->|注文識別子と数量| A
    subgraph A[在庫引当集約]
        S[販売可能数]
        R[確定済み注文の引当]
        S --> R
    end
    A -->|正式な注文確定記録| Q[確定済み注文の読み取りモデル]
function confirmSubmittedOrder(orderId):
    // 提出後の注文内容は変更不能なので、読み取り後に内容が変わらない。
    order = orderRepository.findSubmitted(orderId)
    inventory = inventoryRepository.findFor(order.lines)

    confirmationId = inventory.allocateAndConfirm(
        order.id,
        order.lines,
    )

    inventoryRepository.save(inventory)
    return confirmationId

この再定義では、注文集約を保存して確定済みに変える処理が消える。確定済み注文を表示する読み取りモデルには反映の遅れがあり得るが、「確定した注文には必ず在庫がある」という業務上の強整合は在庫引当集約の記録だけで判定できる。整合性を弱めたのではなく、同じ事実を二箇所で所有していたモデルを、一つの正式な記録へ変えている。

この方法は、注文内容が確定要求より前に変更不能になり、注文確定が在庫引当の成立と同じ意味である場合に限って使える。注文確定が与信審査や契約成立を含む独立した業務上の決定なら、在庫引当だけで注文確定を表すと異なる決定を一つにまとめてしまうため、この再定義はできない。

注文と在庫を一つの集約へ含める

注文確定と在庫確保が別々の事実でありながら、両者を常に不可分に変更する必要があるなら、両方の判定に必要な状態を所有する新しい集約を定義できるかを検討する。たとえば注文内容、確定状態、対象在庫、引当数量を注文履行集約へ含め、注文履行集約だけが確定操作を受け付ける形である。

flowchart TB
    C[注文確定コマンド] --> F
    subgraph F[注文履行集約]
        O[注文内容と確定状態]
        I[対象在庫と引当数量]
        M[確定操作]
        M --> O
        M --> I
    end
    F --> S[集約全体を一つの版として保存]
function confirmOrder(orderId):
    fulfillment = orderFulfillmentRepository.find(orderId)

    fulfillment.confirm()

    orderFulfillmentRepository.save(fulfillment)

class OrderFulfillment:
    function confirm():
        ensureInventoryIsAvailable()
        inventory.reserve(order.lines)
        order.markConfirmed()

この方法が成立するのは、同じ境界へ含める在庫の範囲が有限で、ほかの注文から独立している場合である。通常の在庫は複数の注文から共有され、一つの注文は複数の商品在庫を使う。その関係をすべて一つの集約へ閉じると、同じ商品を使う注文が次々につながり、集約が際限なく広がる。この場合、「強整合が必要だから二つの集約を合体する」という再定義は、実用的な境界を作れない。

同じ種類の集約を束ね直す場合

異なる種類の集約間で決定を調停する注文と在庫の例に対し、同じ種類の複数集約を上位の集約へ束ね直せる場合もある。会議室予約を一件ずつ独立した予約集約として定義した後で、「同じ会議室の同じ時間帯に、確定済みの予約が二件存在してはならない」という不変条件が判明する場合である。

flowchart LR
    U1[予約確定処理 1] --> C1[重複なしと検査]
    U2[予約確定処理 2] --> C2[重複なしと検査]
    C1 --> R1[予約集約 1を確定]
    C2 --> R2[予約集約 2を確定]
    R1 -. 同じ会議室と時間帯 .-> X[重複した確定予約]
    R2 -. 同じ会議室と時間帯 .-> X

この場合は、会議室と一日を識別子とする会議室日程集約を新しい集約ルートにできる。個々の予約は独立した集約ルートではなく、会議室日程集約が所有する予約枠エンティティへ変える。会議室日程集約が現在の予約枠をすべて見てから追加を決めるため、重複禁止の不変条件は一つの集約内に収まる。

flowchart TB
    C[予約コマンド] --> S
    subgraph S[会議室日程集約]
        ROOT[会議室と利用日]
        CHECK[時間帯の重複を検査]
        E1[予約枠エンティティ 1]
        E2[予約枠エンティティ 2]
        ROOT --> CHECK
        CHECK --> E1
        CHECK --> E2
    end
    S --> V[集約全体を一つの版として保存]
function reserveRoom(roomId, date, timeRange):
    schedule = roomScheduleRepository.find(roomId, date)

    schedule.reserve(timeRange)

    roomScheduleRepository.save(schedule)

class RoomSchedule:
    function reserve(timeRange):
        ensureNoReservationOverlaps(timeRange)
        reservations.add(timeRange)

この再定義で、会議室の名称、設備、場所まで会議室日程集約へ入れる必要はない。それらは重複判定に使わないため、会議室集約に残せる。この例は、注文と在庫のような異なる種類の集約を合体する話ではなく、同じ不変条件へ参加する同種の集約を一つの所有者の内部へ移すパターンである。

再定義の判断順序

再定義では、許せない状態、その状態を判定する主体、判定に必要な最小限の情報を順に特定する。最小限の情報を既存の片方へ寄せられるなら、その集約を不変条件の所有者にする。独立した概念として扱うべきなら、引当や割当などの新しい集約へ決定を切り出す。それでも一つの有限な境界へ収まらない場合に、複数集約をまたぐトランザクションか、要求する整合性の緩和を選ぶ。

flowchart TD
    A[集約間で許せない状態を一文で表す] --> B[同じ事実を二つの集約が所有していないか]
    B -->|所有している| C[正式な記録を一つの集約へ移す]
    B -->|別々の事実である| D[必要な状態を有限な一集約へ収められるか]
    D -->|収められる| E[新しい集約へまとめる]
    D -->|収められない| F[複数集約の単一トランザクションを明示的に選ぶ]
    F --> G[難しければ即時整合の要求を見直す]

「一つのデータベーストランザクションでは一つの集約だけを更新する」は境界を考えるための診断規則であり、データベースが複数行を不可分に更新できるという事実を否定する規則ではない。複数集約を同じトランザクションへ入れる実装が直ちに誤りなのではなく、それがなければ業務上の不変条件を守れない場合に、概念上の整合性境界と実装上のトランザクション境界が食い違っていないかを確認するための規則である。

https://zenn.dev/j5ik2o/articles/59de072b6728ff

#domain-driven-design #aggregate #consistency #invariant