DDDを実装していると、よく迷うことがある。

それは、複数の集約をまたぐロジックをどこに置くべきかという問題だ。

たとえば、予約を作る処理を考える。

User を見る
Slot を見る
Reservation を作る
このとき、予約できるかどうかを判断するには UserSlot の両方を見る必要がある。

あるいは、注文処理ならこういうこともある。

Order を作る
Inventory を減らす
Coupon を使用済みにする
Payment を作る

こうなると、どこまでを UseCase に書くべきなのか、どこから Domain Service に切り出すべきなのか、Domain Event に逃がすべきなのかが分かりづらい。

この記事では、複数集約をまたぐロジックの置き場所を、自分なりに整理してみる。

結論

先に結論を書くと、判断軸は次の4つだと思う。

Aggregate:
  その集約自身の不変条件を守る

UseCase / Application Service:
  処理の流れ、取得、保存、トランザクション、外部連携を担当する

Domain Service / Policy / Specification:
  複数集約を見ないと判断できない業務ルールを担当する

Domain Event Handler:
  別集約への副作用で、遅延してもよいものを担当する

もう少し短く言うと、こうなる。

流れは UseCase、判断は Domain Service / Policy、状態の一貫性は Aggregate、副作用は Domain Event。

これを意識すると、複数集約をまたぐ処理でもかなり迷いにくくなる。

Aggregateは整合性境界

まず前提として、DDDにおける Aggregate は単なるオブジェクトのまとまりではない。

Aggregate は、同期的に整合性を守るための境界だ。

つまり、ある集約の内側では、その集約が守るべき不変条件を必ず成立させる。

たとえば Reservation 集約なら、次のようなルールは Reservation 自身が守るべきかもしれない。
キャンセル済みの予約は再度キャンセルできない
予約日時は必ず未来である
予約ステータスは決められた順序でしか遷移しない
これは Reservation の状態に閉じたルールなので、Aggregate の責務として自然だ。 一方で、次のようなルールは Reservation だけでは判断できない。
停止中のユーザーは予約できない
満席の枠には予約できない
同じユーザーは同じ時間帯に複数予約できない
この場合、UserSlot、既存の Reservation など、複数の情報を見る必要がある。

ここで問題になるのが、こうしたロジックをどこに置くかだ。

UseCaseは処理の流れを担当する

UseCase、あるいは Application Service は、アプリケーションとしての処理の流れを担当する。

たとえば、予約作成のUseCaseなら、やることは次のようになる。

入力を受け取る
User を取得する
Slot を取得する
トランザクションを開始する
予約可能か判定する
Reservation を生成する
保存する
必要なら通知やイベント発行を行う

これは、まさに UseCase の仕事だ。

UseCase が複数の repository に依存しているからといって、それだけで悪い設計とは言えない。

複数の集約を取得して、順番に処理を進めるのは Application Service の自然な責務だ。

問題は、UseCase の中に業務判断を直接書きすぎることだ。

たとえば、次のようなコードが増えてくると危ない。

if (user.isSuspended()) {
  throw new Error("停止中のユーザーは予約できません")
}

if (!slot.isOpen()) {
  throw new Error("この枠は予約できません")
}

if (user.hasReservationAt(slot.timeRange)) {
  throw new Error("同じ時間帯に予約があります")
}

もちろん、最初はこれでも動く。

ただ、条件が増えてくると、UseCase はすぐに if の置き場になってしまう。

そうなると、業務ルールの名前が失われる。

「予約可能かどうか」というドメイン上の判断が、UseCase の手続きの中に埋もれてしまう。

Domain Serviceは複数集約をまたぐ業務判断を担当する

複数集約を見ないと判断できない業務ルールは、Domain Service に切り出すと整理しやすい。

たとえば、予約可否の判断なら ReservationPolicy のような名前にできる。
export class ReservationPolicy {
  assertCanReserve(user: User, slot: Slot): void {
    if (user.isSuspended()) {
      throw new Error("停止中のユーザーは予約できません")
    }

    if (!slot.isOpen()) {
      throw new Error("この枠は予約できません")
    }

    if (!slot.canAcceptReservation()) {
      throw new Error("この枠は満席です")
    }
  }
}

UseCase 側はこうなる。

export class CreateReservationUseCase {
  constructor(
    private readonly userRepository: UserRepository,
    private readonly slotRepository: SlotRepository,
    private readonly reservationRepository: ReservationRepository,
    private readonly reservationPolicy: ReservationPolicy,
    private readonly tx: TransactionManager,
  ) {}

  async execute(input: CreateReservationInput): Promise<ReservationId> {
    return this.tx.run(async () => {
      const user = await this.userRepository.findById(input.userId)
      const slot = await this.slotRepository.findById(input.slotId)

      this.reservationPolicy.assertCanReserve(user, slot)

      const reservation = Reservation.create({
        userId: user.id,
        slotId: slot.id,
      })

      await this.reservationRepository.save(reservation)
      return reservation.id
    })
  }
}

この形にすると、責務がかなりはっきりする。

UseCase:
  取得、順序、トランザクション、保存

ReservationPolicy:
  予約できるかどうかの業務判断

Reservation:
  予約そのものの生成と不変条件

UseCase は処理の流れを読みやすく保てる。

一方で、予約可否のルールには ReservationPolicy という名前が付く。

この「業務判断に名前が付く」というのが大きい。

Policyという名前は実務で使いやすい

DDDの用語としては Domain Service と呼べるものでも、実務では Policy と名付けた方が分かりやすいことがある。

たとえば、次のような名前だ。

ReservationPolicy
MissionCreationPolicy
DiscountPolicy
AssignmentPolicy
CancellationPolicy

Policy という名前にすると、「これは処理手順ではなく、判断のまとまりだ」と伝わりやすい。

特に、次のようなメソッド名と相性がいい。

reservationPolicy.assertCanReserve(user, slot)
missionCreationPolicy.assertCanCreate({ drone, pilot, route, slot })
discountPolicy.calculateFor(order, customer)
assertCan...can... という形にすると、UseCase 側から見ても読みやすい。
this.missionCreationPolicy.assertCanCreate({
  drone,
  pilot,
  route,
  slot,
})

この一行を読むだけで、「ここでミッション作成可否を判断している」と分かる。

UseCase に if を並べるより、ドメインの意図が前に出る。

Specificationは条件をオブジェクトとして扱う

Policy と似たものに Specification がある。

Specification は、ざっくり言うと「ある候補が条件を満たすか」をオブジェクトとして表すパターンだ。

典型的には、次のような形になる。

export interface Specification<T> {
  isSatisfiedBy(candidate: T): boolean
}

たとえば、ドローン運航の例ならこう書ける。

export class PilotQualifiedForRouteSpecification
  implements Specification<{ pilot: Pilot; route: Route }>
{
  isSatisfiedBy(candidate: { pilot: Pilot; route: Route }): boolean {
    return candidate.pilot.hasLicenseFor(candidate.route.requiredLicense)
  }
}

Specification は、条件を合成したいときに便利だ。

有効なユーザーである
予約枠が空いている
同じ時間帯に予約がない

こうした条件をそれぞれ Specification として分け、必要に応じて組み合わせることができる。

ただし、実務では注意点もある。

Specification には、repository の検索条件を表す Query Specification と、業務ルールを表す Business Rule Specification がある。

この2つを混ぜると分かりづらくなる。

Query Specification:
  DBから何を検索するかを表す

Business Rule Specification:
  ドメイン上の条件を満たすかを表す
同じ Specification という名前でも、目的が違う。

記事やコードで使うなら、どちらの意味で使っているのかを明確にした方がいい。

個人的には、まずは Policy として業務判断をまとめ、条件が増えて再利用したくなったら内部を Specification に分けるくらいが扱いやすいと思う。

複数集約を同時更新したくなる問題

ここまでの話は、複数集約を参照して、主に一つの集約を作る・更新するケースだった。

しかし実務では、複数集約を同時に更新したくなることがある。

たとえば注文処理だ。

Order を作る
Inventory を減らす
Coupon を使用済みにする
Payment を作る

DDDの原則論としては、一つのトランザクションで複数集約を更新するのは避けたい。

Aggregate は整合性境界なので、境界をまたぐ更新は結果整合性や Domain Event で扱う、というのが基本線になる。

ただ、これを絶対ルールとして扱うと実務ではつらい。

モノリスやモジュラモノリスで、すべて同じDBにあり、一つの操作として強い整合性が必要なら、UseCase で単一のACIDトランザクションにまとめる判断は十分ありえる。

重要なのは、無意識にやるのではなく、トレードオフとして選ぶことだと思う。

同じDBで完結する
同じユーザー操作の一部である
失敗したら全部戻したい
分散システムではない

この条件なら、単一トランザクションでまとめた方がシンプルなことも多い。

逆に、次のような場合は Domain Event や Saga を検討する。

別サービスにまたがる
別DBにまたがる
多少遅れて反映されてもよい
通知、分析、請求連携など副作用に近い

ただし Saga は便利な魔法ではない。

補償処理、冪等性、リトライ、監視、デバッグなどのコストが増える。

本当に分散整合性が必要なときだけ持ち込むべきだと思う。

Domain Eventは遅延可能な副作用に使う

Domain Event は、ドメイン上意味のある出来事を表す。

たとえば次のようなものだ。

ReservationCreated
OrderPlaced
PaymentCompleted
MissionCreated

Domain Event が向いているのは、主処理のあとに発生する副作用だ。

予約が作られたのでメールを送る
注文が確定したので在庫引当を開始する
ミッションが作られたので通知する
支払いが完了したので請求履歴を作る

これらは、必ずしも集約のメソッドの中で直接実行する必要はない。

むしろ、集約はイベントを記録し、UseCase や Unit of Work がコミット前後でイベントを処理する形の方が扱いやすい。

const reservation = Reservation.create({
  userId: user.id,
  slotId: slot.id,
})

reservation.recordEvent(new ReservationCreated(reservation.id))

ハンドラ側で通知や別集約への反映を行う。

export class SendReservationCreatedEmailHandler {
  async handle(event: ReservationCreated): Promise<void> {
    await this.mailer.sendReservationCreated(event.reservationId)
  }
}

同じプロセス内で同期的に処理するのか、メッセージキューへ流して非同期に処理するのかは、要件次第だ。

分散システムへ出すなら、Outbox Pattern や冪等性もセットで考える必要がある。

集約境界を見直すサイン

もし毎回のように複数集約を強整合で同時更新したくなるなら、集約境界を見直すサインかもしれない。

たとえば、OrderOrderLine を別集約にしているが、注文更新のたびに必ず一緒に更新するなら、同じ集約にした方が自然かもしれない。 逆に、OrderPayment はライフサイクルも責務も違うので、別集約としてイベント連携した方が自然かもしれない。

判断軸は、次のようなものだ。

同じ不変条件を守る必要があるか
同じライフサイクルで変化するか
毎回一緒に読み書きされるか
片方だけ独立して存在できるか
遅延反映を許容できるか

同じ不変条件を守るなら、同じ集約に寄せる。

ライフサイクルが違い、遅延反映できるなら、別集約として Domain Event でつなぐ。

この見直しをせずに、UseCase や Domain Service だけで無理に吸収しようとすると、設計がどんどん苦しくなる。

ドローン運航管理で考える

たとえばドローン運航管理で、ミッションを作成するケースを考える。

Drone
Pilot
Route
DeliverySlot
Mission

ミッションを作れるかどうかは、複数の情報を見ないと判断できない。

ドローンが利用可能か
操縦者が資格を持っているか
ルートが飛行可能か
時間枠が空いているか
他ミッションと衝突しないか
この場合、UseCase は各集約を取得し、MissionCreationPolicy に判断を委譲する。
export class CreateMissionUseCase {
  constructor(
    private readonly droneRepository: DroneRepository,
    private readonly pilotRepository: PilotRepository,
    private readonly routeRepository: RouteRepository,
    private readonly deliverySlotRepository: DeliverySlotRepository,
    private readonly missionRepository: MissionRepository,
    private readonly missionCreationPolicy: MissionCreationPolicy,
    private readonly tx: TransactionManager,
  ) {}

  async execute(input: CreateMissionInput): Promise<MissionId> {
    return this.tx.run(async () => {
      const drone = await this.droneRepository.findById(input.droneId)
      const pilot = await this.pilotRepository.findById(input.pilotId)
      const route = await this.routeRepository.findById(input.routeId)
      const slot = await this.deliverySlotRepository.findById(
        input.deliverySlotId,
      )

      this.missionCreationPolicy.assertCanCreate({
        drone,
        pilot,
        route,
        slot,
      })

      const mission = Mission.create({
        droneId: drone.id,
        pilotId: pilot.id,
        routeId: route.id,
        deliverySlotId: slot.id,
      })

      await this.missionRepository.save(mission)
      return mission.id
    })
  }
}

このときの責務はこうなる。

CreateMissionUseCase:
  取得、トランザクション、保存、処理順序

MissionCreationPolicy:
  ミッション作成可能かの総合判断

DroneAvailability:
  機体状態に関する判定

PilotQualification:
  操縦資格に関する判定

RouteConflictChecker:
  経路競合に関する判定

Mission:
  ミッション自身の不変条件と状態遷移
もし Drone の状態変更や Pilot の割当状態変更を同時に行いたいなら、それが本当に同一トランザクションで必要なのかを考える。 遅延してよいなら MissionCreated イベントへ逃がす。

毎回必ず強整合で必要なら、集約境界やトランザクション設計を見直す。

判断フロー

最後に、置き場所の判断フローをまとめる。

1. そのルールは一つの集約の状態に閉じているか
   yes -> Aggregate に置く

2. 複数集約を見ないと判断できない業務ルールか
   yes -> Domain Service / Policy / Specification に置く

3. 取得、保存、トランザクション、外部連携、処理順序か
   yes -> UseCase / Application Service に置く

4. 他集約への副作用で、遅延してもよいか
   yes -> Domain Event Handler に置く

5. 毎回、複数集約を強整合で同時更新したいか
   yes -> 集約境界を見直すか、単一トランザクションを意識的に選ぶ

このフローにすると、UseCase、Domain Service、Aggregate、Domain Event の責務が混ざりにくくなる。

まとめ

DDDで複数集約をまたぐロジックを書くとき、すべてをUseCaseに書くと手続き的になりすぎる。

一方で、すべてをDomain Serviceに寄せると、今度はDomain Serviceが巨大な神クラスになりやすい。

大事なのは、「そのコードは何をしているのか」を見ることだ。

処理の流れなのか
業務判断なのか
集約自身の不変条件なのか
遅延可能な副作用なのか

これで置き場所はかなり決めやすくなる。

自分の中では、次の整理が一番しっくりきている。

Aggregate:
  自分自身の不変条件を守る

UseCase / Application Service:
  流れ、取得、保存、トランザクション、外部連携を担当する

Domain Service / Policy:
  複数集約をまたぐ業務判断を担当する

Specification:
  再利用可能な条件をオブジェクトとして表す

Domain Event Handler:
  遅延可能な副作用を担当する

そして、毎回複数集約を強整合で同時更新したくなるなら、まず集約境界を疑う。

それでもモノリスとして単一トランザクションで扱う方が単純なら、それを明示的なトレードオフとして選ぶ。

DDDの原則は大事だが、実務では原則を守ること自体が目的ではない。

業務ルールがどこにあり、どの整合性を守る必要があり、どこまでを同期的に扱うべきかを明確にすることが重要だと思う。