集約とクラス不変表明
DDDには集約という概念があります。これが本当に難しくて、どうモデリングしてあげるべきなのかが掴めないでいました。しばらくDDDを実践していくなかで、 バートランド・メイヤーのオブジェクト指向入門(通称OOSC)の契約による設計の章を読み返していたら、少し掴めた気がしたのでここにまとめたいと思います。
概要
- 集約で重要な考え方は「不変条件」と「トランザクション」
- トランザクションの方が取り上げられがちだが、重要なのは「不変条件」の方
- 不変条件 = クラス不変表明
- 集約は不変条件をオブジェクトのライフサイクル(生成 -> 保存 -> 再構築)の中で常に守らせるための実装パターン
Evans本の集約
まずはEvans本の集約の章を見てみます。
複雑な関連を伴うモデルでは、オブジェクトに対する変更の一貫性を保証するのは難しい。維持すべき不変条件には、個々のオブジェクトに適用されるものだけでなく、密接に関連するオブジェクトのグループに適用されるものもある。だが、慎重にロックしすぎると、今度は複数のユーザが指針もなく相互に干渉し合い、システムが使いものにならなくなる。 ... 集約とは、関連するオブジェクトの集まりであり、データを変更するための単位として扱われる。各集約にはルートと境界がある。境界は集約の内部に何があるかを定義するものだ。ルートは集約に含まれている特定の 1エンティティである。
集約において重要なのは下記の二点だと考えます。
- 不変条件
- トランザクション
Evans本でも、集約の説明はトランザクションへの言及が多くなっています。しかし、集約においてまず第一に重要なのは「不変条件」の方です。集約のモデルが不変条件を維持することに加えて、どのように永続化時の競合を回避するのか?それをまとめたものが「集約」です。
上記の「不変条件」はオブジェクト指向の「クラス不変表明」に対応します。
不変条件 = クラス不変表明
ちなみに、「不変」って出てきていますが、ここでいう「不変」は immutable ではなく、invariant の方です。
クラス不変表明(class invariant)
では、クラス不変表明についてさらっていきましょう。クラス不変表明は契約による設計で出てくる概念です。
事前条件と事後条件は個々のルーチンの特性を記述する。このほかに、すべてのルーチンで維持されなければならない、クラスインスタンスに共通する全体的な特性を表す必要がある。そのような特性は、クラス不変表明(class invariant)といい、クラスの深い意味的た特性をとらえ、整合性を強いることで、クラスを特徴付ける。
ソフトウェアの重要な仕事に状態の変更があります。状態は複数のオブジェクト(エンティティや値オブジェクト)が複雑に絡み合って維持しなければならない条件があることがあります。クラスが常に維持しなければならない条件を満たしていることを保証するのがクラス不変表明です。これは、事前条件、事後条件を組み合わせて実現されます。
不変表明規則
表明が正しい不変表明になっているかは次の不変表明規則を満たしていることで確認できます。
次の2つの条件が満たされている場合に限り、表明 l は、クラス C の正しいクラス不変表明である。
- E1: C の生成プロシージャすべてにおいて、属性がデフォルト値の状態で、事前条件を満たす引数を使い、その結果、l が成立する。
- E2: クラスのエクスポートされたルーチンすべてにおいて、l とルーチンの事前条件の両方を満たす状態で引数を使い、その結果、引き続き l が成立している。
Scalaの場合だと、生成プロシージャはプライマリコンストラクタ(Scalaはクラス本体がプライマリコンストラクタとしての挙動をする)、事前条件は require 句、エクスポートされたルーチンは public メソッドのことです。
つまり、生成時とミューテーションの前後で常に不変条件満たすように実装すれば良いということです。Scalaだと通常imutableに実装するので、基本的にはプライマリコンストラクタに制約をつけておけば達成できると思います。
具体例
具体例で見ていきましょう。Evans本の購入注文の整合性を例に使います。
ここの具体例で出てくるコードは下記のリポジトリに置いてあります。
集約ルートとして購入注文(PurchaseOrder), その集約内部エンティティとして購入注文品目(PurchaseOrderLineItem)があり、その他に商品 Entity があります。(商品の価格は変動すると思ったので、Entityにしましたが、ここではValueObjectとしてモデリングした方が良かったかもしれません。)
ここで重要なのは、不変条件の矢印のところです。「注文品目総額が承認限度額以下である」の部分です。これを満たすように実装します。
上記の使用に従い実装してみた PurchaseOrder
クラスをまずは下記に示します。
少し長いですが、分解してみていきます。何度も重要だと言っている不変表明(不変条件)部分は下記です。購入注文モデルは、注文総額が承認限度額以下でなくてはならないという不変条件(これは、業務上の仕様にあたる)を持つので、それをここでは require
を使って表現しています。
// クラス不変表明 private def invariant(): Unit = { require( mustLessEqualThanApprovedLimit(total, approvedLimit), // <--- ここが、注文総額が承認限度額以下であることを期待している。 s"$MustLessThanEqualApprovedLimitMessage, 総額($total), 承認限度額($approvedLimit) " ) require(mustNotDuplicateOrderLineItemId, "購入注文品目のIDは集約内部で重複しない") // <--- 集約内部エンティティである、PurchaseOrderLineItemのIDが重複しないこと表明で担保している。 } }
これにより、このクラスは不変条件を常に保つことが約束されました。この集約単位でトランザクションしてあげれば、常に不変条件は保たれることになります。この単位で永続化するというのが Repository
になるわけです。永続化を集約単位でのみ行うことで、オブジェクトのライフサイクルの間ずっと不変条件が保たれるようになります。
trait PurchaseOrderRepository extends Repository[PurchaseOrder] { def findById(id: PurchaseOrderId): Option[PurchaseOrder] def insert(purchaseOrder: PurchaseOrder): Unit def update(purchaseOrder: PurchaseOrder): Either[String, Unit] def delete(id: PurchaseOrderId): Unit }
しかし、これが例えばこんな実装だとしたらどうでしょうか?
object PurchaseOrderLineItemDAO { def addNewLineItem(purchaseOrderId, newPurchaseOrder: PurchaseOrderLineItem): Unit = { ... } }
集約の考え方を知らなけれ自然な実装のように感じます。ある購入注文に、新たな注文品目を追加するという実装です。具体的な実装では、シンプルに PurchaseOrderLineItem
が対応するテーブルに新たなレコードが追加されるでしょう。この時、少しマシな実装者ならば、insert する前に追加後の購入注文の総額が承認限度額を超えていたら検査例外(ScalaならEitherのLeft)を返してくるでしょう。
この実装は私が思うに3つ問題があります。
- 単一責任原則に違反している
- 仕様がDB接続部分の具体的な実装の方に書かれてしまっている。
- 不変条件が保てない
単一責任原則に違反している
単一責任原則とは
モジュールを変更する理由はたったひとつであるべきである
という原則です(Clean Architectureでは少しレベルアップして単一のアクターに対して〜となっているのですが、ここでは変更理由が一つの方が分かりやすいのでそちらを採用します。)
上記の実装には少なくとも二つの変更理由が存在します。
- 業務仕様(注文総額は承認限度額以下でなくてはならない)の変更
- DB関連の変更(スキーマ設計やライブラリの変更など)
この理由はわかりやすいですね。業務的な知識と、永続化の手段は分けるべきです。
仕様がDB接続部分の具体的な実装の方に書かれてしまっている
これは先ほどの単一責任原則違反とも関連しますが、ドメイン知識がインフラストラクチャのレイヤに漏れ出してしまっています。SQLやその他製品との具体的な実装部分は複雑で難解になりやすく、そこに業務ロジックを記載してしまうと後でみた開発者が業務仕様をコードから読み解くことが困難になってしまいます。
不変条件が保てない
これは正直実装の仕方次第なのですが、人間、不注意な生き物ですし、開発者なので注意して見るとかいう無駄な努力で解決したくもありません。何の規律もなくDBを更新する実装を書かれてしまっては、あなたは繊細な注意を払って不変条件を守ったとしても(それも怪しいものですが)、別の誰かが必ず不変条件を破りにきます。それを防ぐのに集約という実装パターンが役に立ちます。
まとめ
集約はトランザクション整合性の方に焦点がいきがちですが、一番重要なのは不変条件(クラス不変表明によって実現される)の方です。不変条件を保つべき単位でRepository(のみ)を介してトランザクションを行うことで、オブジェクトのライフサイクルの間で常に不変条件を守らせようとするのがこの、集約という実装パターンだと考えます。
追記 2022/5/1
Evans本を読み返していたら、しなやかな設計の章で「表明」取り扱われていたし、集約との関係にも言及されていました。後ろの方もちゃんと読まないとな。。
「契約による設計」学派は次の段階に進み、クラスとメソッドについて、開発者によって保証される「表明」を作成する。このスタイルは『オブジェクト指向入門』(Meyer 1988)において詳述されている。要約すると、「事後条件」(post-conditions)が操作の副作用、つまり、メソッドを呼び出すことで保証される結果を記述する。「事前条件」(preconditions)とは契約にあるただし書きのようなもので、事後条件が成り立つことを保証するために満たさなければならない条件のことである。クラスの不変条件は、あらゆる操作が終わった時のオブジェクトの状態に関する表明となる。不変条件は、集約全体に対しても宣言することができ、整合性に関するルールを厳密に定義する。