kbigwheelのプログラミング・ソフトウェア技術系ブログ

プログラミング・ソフトウェア技術系のことを書きます

集約の境界と整合性の維持の仕方に悩んで2ヶ月ぐらい結論を出せていない話

本記事はドメイン駆動設計 Advent Calendar 2018 - Qiitaの3日目の記事です。

2日目は、grimroseさんのぐるぐるDDDで気をつけてることでした。
4日目は、s_edwardさんのMicroservices と DDDです。

Table of Contents

以下の記事を読むにあたり前提となる知識

以下の話はドメイン駆動設計および実践ドメイン駆動設計を読んでいることがある程度前提になります。 特にIDDD第10章「集約」で説明されている用語定義や例に強く依存しています。ご了承ください。

またサンプル中のコードはScalaで書かれています。それほど難しい構文は出てきませんが、多少の知識がないとわかりづらいかもしれません。

問題

現在仕事で関わっているサービス開発において、集約の境界と集約間の整合性の取り方をどうするか(トランザクション制御 or 結果整合性)について悩んでいます。

サービス詳細

後の説明でイメージしやすいようにするため、以下のようなサービスをイメージしてください。

  • 1種の社内向けブログサービス
  • 有償サービスであり、個々人ではなく組織単位での課金となる
  • サービス料金は組織内の有効ユーザー数1による(Github Teamライク)

ユビキタス言語

ユビキタス言語 詳細
ユーザー 記事を書く人。
メールアドレス・パスワードによって一意に特定される。
また特定の実在個人と1対1でマッピングされる。
ユーザーは必ず1つの組織に所属する。
組織は有効ユーザー数上限に達しない限りはいつでもユーザーを追加できる。
ユーザーの有効/無効 本サービスではユーザーは削除することができず、無効化のみすることができる。
無効状態のユーザーは有効ユーザー数上限に達していない限り再び有効化できる。
無効状態のユーザーは記事を新たに書くことができない。
無効状態のユーザーは有効ユーザー数にカウントされない。
記事 いわゆる普通のブログ記事。
ユーザーによって書かれる。
組織 複数のユーザーを含む。
ユーザー数は場合によっては数千にもなりうる。
契約はこの組織単位でのみ行われる。
また契約時にはアクティブユーザー数上限を決める。
有効ユーザー数上限 組織は有効状態のユーザーが契約上の有効ユーザー数上限に達しているとき、新たにユーザーを追加できない。

※ 実際にはこんなに単純ではないと思いますが主題にフォーカスするため絞って書いています。

重要なビジネスルール

  • 組織内の有効状態のユーザーの総数は契約時の有効ユーザー数上限を常に超えない(以後ユーザー上限ルール)
  • 無効状態のユーザーは記事を書くことができない

モデリング

以上をドメインエキスパートから聞き取りを行ったのち、一旦以下のように集約やエンティティをモデリングしました。

基本的にモデリング過程はDDD本例6.1の「購入注文の整合性」およびIDDD本10章の例を踏襲しました。 組織、ユーザー、記事はそれぞれが一つのエンティティでありかつそのエンティティ1つのみを含む集約の集約ルートでもあります。 IDDDで推奨される集約間はIDでのみ相互参照するべき、というプラクティスに従い、組織とユーザーおよびユーザーと記事の親子関係はID値オブジェクトで表現しました。

リポジトリのメソッドはscalikejdbcで自動生成されるものと同じ機能とします2

上の何が問題?

重要なビジネスルールの1つであるユーザー上限ルールが組織とユーザーという2つの集約にまたがってしまっています。その結果、ユーザー上限ルールを維持することが難しくなっています。

// ドメイン層のどこか

def `ユーザー追加`(
    `組織ID`: Int,
    `ユーザー名`: String,
    `メールアドレス`: String,
    `ハッシュ化されたパスワード`: String): Try[`ユーザー`] = {
  val `組織インスタンス` = `組織リポジトリ`.find(`組織ID`).get
  val `有効ユーザー数` = `ユーザーリポジトリ`.count有効ユーザー数(`組織ID`) // ①
  if (`有効ユーザー数` < `組織インスタンス`.`有効ユーザー数上限`) // ②
    Success(`ユーザーリポジトリ`.create(`組織ID`, `ユーザー名`, `メールアドレス`, `ハッシュ化されたパスワード`)) // ③
  else
    Failure(new `有効ユーザー数上限Exception`)
}

IDDD本に従いトランザクション制御は集約内(≒単一リポジトリ内)で完結させているとすると3、上のコードは一切集約間の整合性の維持を気にかけていません。ですのでユーザー上限ルールは以下のように容易に破綻します。

  1. 有効ユーザー数上限が30, 現在の有効ユーザー数が29
  2. ユーザー追加メソッドが並行でほぼ同時に2度呼ばれる(それぞれをコールA, コールBとする)
  3. コールAで①が実行され、29が取得される
  4. コールBで①が実行され、29が取得される
  5. コールA/Bどちらでも②は29 < 30であるため③に進む
  6. コールAで③が実行される
  7. コールBで③が実行される
  8. 最終的に有効ユーザー数は31となり、有効ユーザー数上限を超えてしまう

DDD的には以下の4つの解決策が提示されています。

解決策

解決策1 集約をマージする

2つの集約が1つになればそのままトランザクション制御で整合性を維持できるので一時的な整合性の破綻も起きなくなります。DDD本例6.1では集約A(注文書)と集約B(個別の品目リスト)を一つの集約として扱えると判断した結果、この問題を解決しています。

ただし集約内の要素へのアクセスは必ず集約ルートを通じて行う必要があるため、ユーザーが独立した集約だったときのように記事エンティティがユーザーIDを持つことはできません。 このアイデアを適用使用した結果、最終的に以下のようなモデルになりました。

ユーザーエンティティと組織エンティティを一つの集約にしたことにより、ユーザー追加メソッドは以下のようになりました。

def `ユーザー追加`(
    `組織ID`: Int,
    `ユーザー名`: String,
    `メールアドレス`: String,
    `ハッシュ化されたパスワード`: String): Try[`ユーザー`] = {
  val `組織インスタンス` = `組織リポジトリ`.find(`組織ID`).get // ①
  if (`組織インスタンス`.`有効ユーザー数が上限に達している`()) {
    val `ユーザーインスタンス` = `組織`.addユーザー(`組織ID`, `ユーザー名`, `メールアドレス`, `ハッシュ化されたパスワード`)
    `組織リポジトリ`.save(`組織インスタンス`) // ②
    Success(`ユーザーインスタンス`)
  } else
    Failure(new `有効ユーザー数上限Exception`)
}

もし仮に2つのメソッドコールが同時並行に走ってA①, B①, A②, B②と実行されたとしても、バージョン番号による楽観的ロックを行っていればB②でエラーが発生します。その場合は再度このメソッドをやり直せば良いです。 あるいはユーザー追加メソッド自体をリポジトリのメソッドにすれば楽観的ロックを使わずトランザクション制御を直接使うこともできます4

この方法の問題は大きく2つあります。 1つは集約が大きくなることで、今回の場合ユーザー数は1000を超える可能性がありメモリ消費量5や同時編集の可能性、クエリのパフォーマンスの劣化などの問題が出てきます。 もう1つはオブジェクトグラフが複雑になることで、ユーザーエンティティが集約ルートではなくなるため記事エンティティは必ず組織集約ルートにアクセスしてからユーザーへアクセスする必要があります。 例えば記事IDから記事タイトルとユーザー名をペアにして返すようなメソッドを書く場合、以前であれば記事エンティティ内のユーザーIDから単純に辿れたものが、今の状態だと組織リポジトリのfindユーザーメソッドを使う必要があります6

  • 利点
    • 整合性の一時的な破綻の可能性はなくなる
  • 欠点
    • ユーザーエンティティの情報を参照するときに必ず組織集約ルートを経由する必要があるためややこしくなる
    • 集約が大きくなることの影響(更新のコンフリクト・ロック時間の増大・DBからの読み込み/への書き込みパフォーマンスの悪化)

解決策2 一時的な整合性の破綻を受け入れ結果整合性を使う

IDDD本の書くとおり、複数の集約間の整合性は結果整合性で行うパターンです。

モデルは以前のままですが、コードに手を入れる必要があります。

def `ユーザー追加`(
    `組織ID`: Int,
    `ユーザー名`: String,
    `メールアドレス`: String,
    `ハッシュ化されたパスワード`: String): Try[`ユーザー`] = {
  val `組織インスタンス` = `組織リポジトリ`.find(`組織ID`).get
  def `有効ユーザー数`(): Int = `ユーザーリポジトリ`.count有効ユーザー数(`組織ID`)
  if (`有効ユーザー数`() < `組織インスタンス`.`有効ユーザー数上限`) {
    val `ユーザーインスタンス` = `ユーザーリポジトリ`.create(`組織ID`, `ユーザー名`, `メールアドレス`, `ハッシュ化されたパスワード`
    if (`組織インスタンス`.`有効ユーザー数上限` < `有効ユーザー数`()) { // ①
      `ユーザーインスタンス`.`無効化`()
      `ユーザーリポジトリ`.save(`ユーザーインスタンス`)
      Failure(new `有効ユーザー数上限Exception`)
    } else
      Success(`ユーザーインスタンス`)
  } else
    Failure(new `有効ユーザー数上限Exception`)
}

正直なところ、今回のケースでうまく結果整合性により整合性を維持する方法がうまく浮かびませんでした。 そこで、ユーザー追加後にもう一度有効ユーザー数をチェックしてもし上限を超えていた場合は作成したユーザーを無効化しています。 ただ、この手法にも多くの問題があります。最も問題なのは①のタイミングでDBが停止したりアプリケーションが停止した場合に整合性が破綻したままで残ってしまう問題です。 KinesisやKafkaのような永続化キューを経由することでその問題は解決できますが、今までドメインイベントやPub/Subモデルを利用していなかった場合はかなり大掛かりな変更になるでしょう。 他にも無効化したユーザーが残ってしまう点も問題になるケースはあるかもしれません。例えばユーザーエンティティ単体でメールアドレスが一意になる制約を課している場合、結果整合性のために自動的に無効化されたユーザーがいるとハンドリングが難しくなる気はします。

  • 利点
    • 集約のモデリングは自然なまま。ユーザー集約と他の集約間の関係も自然に表現できる
    • 集約の粒度も小さく維持できるのでパフォーマンスは良いまま競合やデッドロックなどの危険性を最小にできる
  • 欠点
    • 瞬間的とはいえ整合性が一時的に破綻することを許容しないといけない
    • トランザクション制御と比べ確実に整合性を維持するためのコードは増える。また仕組みも大掛かりになる

解決策3 アンチパターンではあるが集約間の整合性維持のためトランザクション制御を用いる

IDDD本では解決策1でも2でも4でもうまくいかないときの例外として、複数の集約間でトランザクション制御を使う場合について言及しています。 今回のケースだとIDDD本p354の「理由その2:技術的な仕組みの欠如」に当たるでしょう。

モデルはほとんど以前のままですが、集約エンティティに現在の有効ユーザー数とそれを増減させるメソッドを持たせています。

def `ユーザー追加`(
    `組織ID`: Int,
    `ユーザー名`: String,
    `メールアドレス`: String,
    `ハッシュ化されたパスワード`: String): Try[`ユーザー`] = {
  DB localTx { session =>
    val `組織インスタンス` = `組織リポジトリ`.find(`組織ID`)(session).get
    val `有効ユーザー数` = `ユーザーリポジトリ`.count有効ユーザー数(`組織ID`)(session)
    if (`有効ユーザー数` < `組織インスタンス`.`有効ユーザー数上限`) {
      val `ユーザーインスタンス` = `ユーザーリポジトリ`.create(`組織ID`, `ユーザー名`, `メールアドレス`, `ハッシュ化されたパスワード`)
      `組織リポジトリ`.save(`組織インスタンス`.`有効ユーザー数+1`())(session)
      Success(`ユーザーインスタンス`)
    } else
      Failure(new `有効ユーザー数上限Exception`)
    }
}

解決策4 ユースケースの見直しによる再モデリング

そもそものモデルのユースケースを見直すことで、集約やエンティティなどを再度モデリングする方法です。 ドメインエキスパートを交えてある程度検討してみたのですが、もともとユーザー追加というかなり単純なユースケースであることもあってユースケースの見直しというアプローチは厳しそうでした。

まとめ

とりあえず今どうやっているか

解決策2の結果整合性による手法を取っています。 とはいえこれは積極的な選択によるものではなく、もともとそうやっていたこと、実際に破綻が起こる可能性はほぼありえないことなどが理由です。

ただ、ほとんど起こり得ないとはいえ整合性が破綻した瞬間にアプリケーションが停止するとDBの整合性が取れなくなる可能性があること、現状それを能動的に検知する手段を確立していないことなどは確実に心理的負担になっています。

最終的にどうするべきだと考えているか(2018/12/01時点)

解決策2の結果整合性による方法か、解決策3の部分的なルール違反を受け入れるかのどちらかだと思っています。 ただし前者を選択する場合は今よりもっとコードを書く必要があるでしょう。また今まで使用していないドメインイベントや永続化キューの導入が必要になります。 後者の場合はそのような仕組みづくりは必要ありませんが、集約をまたがってトランザクションを制御する必要があるためそれをドメイン層やプレゼンテーションアプリケーション層へ持ち込むのか、持ち込む場合はどういった方法を取るのか(トランザクションの概念を抽象化する/しない)、持ち込まない場合はどうやってインフラストラクチャ層でトランザクション制御を完結させるのかなどを検討する必要があります。

ソリューション募集中

こうしたらいいんじゃないか、この論理展開おかしくない?エヴァンスやヴァーノンはそうは書いていないぞ/解釈間違っているぞ、等々ありましたら是非教えてください。twitterでもブログのコメント欄でもなんでも構わないです。

2018/12/04 追記

2,3人ぐらいから反応してもらえたら御の字と思っていたんですが想定していた以上のフィードバックがあって本当に嬉しく思っています。 このままだと頂いたアイデア・考えが散ってしまってもったいないので、できれば今週中ぐらいで頂いたアイデア・考えをまとめて(あとそれらを教えてもらった上で今僕がどうするべきだと思っているかも)このブログの次の記事としてアップしようと思います。乞うご期待ください。

2018/12/10 追記

書きました!

kbigwheel.hateblo.jp

参考文献


  1. 詳細は後述

  2. ファクトリとリポジトリが一緒になっていてよくない、というのはあるかもしれません

  3. グローバルトランザクションなどで暗黙的なトランザクション共有もしていないということ

  4. これでも良いはずですが、今回は組織エンティティにaddユーザーメソッドがあったほうがより表現が豊かかと思い例ではそのようにしました

  5. 集約内のすべての値オブジェクト・エンティティは基本的に一度にロードされます

  6. これの何が問題かというと、記事とユーザーという2要素だけで考えられた処理に組織という概念の不純物が入ってしまう点です

  7. とはいえグローバルトランザクションよりは遥かに危険性は抑えられているのですが