こんにちは。
最近、こんなツイートしたのですが、ドメインオブジェクトではなくアプリケーションサービス1などにドメインロジックが書かれてしまうことがあります。
アプリケーションサービスはドメインロジックを配置する場所ではない、それはドメインオブジェクトの役割。アプリケーションサービスは進行役。ここを間違うから簡単にドメインモデル貧血症になってしまうんだと思います。
— かとじゅん (@j5ik2o) August 18, 2019
最近、以下の書籍(以下 増田本)をマジメに読み直しました(笑)。ドメインモデル貧血症2を回避して、ドメインロジックをドメインオブジェクトに凝集させる方法に関して、増田本にいろいろ書いてあったので、そのエッセンスと僕の考察を交えて解説したいと思います3。 詳しい内容は以下の増田本を読んでください! コード例はScalaですが難しい表現がないので、Scalaが分からない人でも擬似コードとして読めると思います(後述する数量クラスは増田本の中に出てくる例をScalaで書き直したものです)
ドメイン固有型を定義してドメインロジックを凝集させる
その型 本当にあってますか?
ドメインロジックで扱う情報として、商品の数量や顧客の電話番号があります。これらの情報の型を単なる整数や文字列とすることがあります。例えば数量なら以下のように。
class Order(orderId: OrderId,
itemId: ItemId,
quantity: Int, // 数量
orderDateTime: LocalDateTime) {
// ...
}
この場合、数量の値の範囲は-21億から21億です。この値の範囲はほとんどの場合要求にあっていません。まず負の数値はないし、正の数でも21億も必要ないでしょう。仮にquantity: Int
をは0〜100とした場合、期待どおりに動作するかOrderのテストで検証が必要です。さらにquantity: Int
を使うクラスが他にもあれば、その単位ごとに検証が増えます…。増田本では「CHAPTER1 小さくまとめてわかりやすく」の「値の範囲を制限してプログラムをわかりやすく安全にする」に詳しく解説されていて、まさにそのとおりだと思いました。この数量が金融商品だったら…怖いですね…。いや金融商品でなくても怖いですね…。4
ドメインオブジェクトは不変条件を表明する
注文時の数量クラスが仕様の範囲を超えないように、不変条件を表明するとよいでしょう。以下のコードは指定した条件を満たしてなければ、IllegalArgumentExceptionがスローされます。しかし、購入クラスでも数量を扱うことになれば、また同様の表明が必要になります…。
class Order(/** ... */,
quantity: Int, // 数量
/** ... */) {
require(quantity >= Order.MinQuantity, s"The quantity is less than min quantity(${Order.MinQuantity})")
require(quantity <= Order.MaxQuantity, s"The quantity is greater than max quantity(${Order.MaxQuantity})")
}
値の種類ごとにドメイン固有型を定義する
注文クラス、購入クラスなどのドメインオブジェクトごとに数量が正しいか表明する…。これらのクラスの責任でしょうか…。数量はもはや単なるInt
ではないと捉えて、以下のように数量クラス(Quantity
)というドメイン固有型を作り、役割を与えた方がよいでしょう。 数量だけでなく、金額や日付など他にも値の種類がある場合は、それぞれに型を定義します。
値の範囲について制約を課せるようになりましたが、内部データを取り出せるgetValue
というGetterが問題です。アプリケーションサービスでこのGetterを使ってドメインロジックを書けてしまいます…。アプリケーションサービスは進行役なのに、これでは責務違反になってしまいます。
class Quantity(value: Int) {
require(value >= Quantity.MinValue, s"The quantity is less than min quantity(${Order.MinQuantity})")
require(value <= Quantity.MaxValue, s"The quantity is greater than max quantity(${Order.MaxQuantity})")
def getValue: Int = value
}
class Order(/** ... */
quantity: Quantity, // 数量
/** ... */) {
// ...
}
class Purchase(/** ... */,
quantity: Quantity, // 数量
/** ... */) {
// ...
}
例えば、現状の数量クラスで数量の加算処理は以下のようなコードになります。q2
は加算処理ですが、ユビキタス言語が使われていないので、一見して設計の意図がわかりにくい。それと、加算処理の定義と評価が同じ箇所に記述されていて、複数箇所にあると変更が厄介です。進行役であるアプリケーションサービスに、こういう具体的なビジネスロジックを書いてしまっているなら、見直しが必要です。
val q1 = new Quantity(10)
val q2 = new Quantity(q1.getValue + 1)
println("%0d".format(q2.getValue))
こういった役に立つ振る舞いをひとつも提供しないオブジェクトを、 貧血症オブジェクト や ドメインモデル貧血症 と呼ぶことがあります。貧血症オブジェクトの設計上の最も大きな問題は、Getできるとドメインオブジェクトの外側にドメインロジックを書けてしまい、数量の操作に関わる知識が分散してしまうことだと思います。
蛇足ですが、トランザクションスクリプト 5では、データを持つクラスとロジックを持つクラスに分けます。ドメインオブジェクトの外側にドメインロジックが書かれてしまう状況は、これに似ています。トランザクションスクリプトの弊害は、ロジックが重複することに気づくのが難しくなったり、どこにどんなロジックがあるか見通しが悪くなります。貧血症オブジェクトでも、ドメインロジックの凝集という観点では同様の悪影響を受けます。
Getterではなく、内部データを使った計算の結果を返すメソッドを提供する
前述の問題を避けるには、内部データを暴露するGetterを使わないで、ドメインロジックをドメインオブジェクト内に定義しなければなりません。その対策として、以下のように、Getter経由で内部データを暴露せずに、数量を操作する加算メソッドを提供します。
val q1 = new Quantity(10)
val q2 = q1 + 1 // +はメソッド名。もしくは q1.add(1)
println(q2.asString(NumberFormat.getIntegerInstance)) // 内部データの文字列表現
このあたりの話も、増田本の「CAPTER3 ロジックをわかりやすく整理する」の「メソッドをロジックの置き場所にする」や「業務ロジックをデータを持つクラスに移動する」に解説があります。読んでみてください。
前述の例で示したとおり、数量に関する操作は数量クラスに凝集させます。数量に関する知識は数量クラスにまとまります。以下は数量クラスにadd
や+
のメソッドを追加した例です。これらのメソッドは、もはやGetterではありません。増田本では、このような計算を業務ロジック(判断/加工/計算を行う演算)と定義しています。まさしく、ドメイン知識を表す役に立つ振る舞いのことです。Getterを提供するのがドメインオブジェクトの責務ではないということですね。
object Quantity {
val Min = 1
val Max = 100
// IntをQuantityに暗黙的型変換。Intを指定されてもQuantityに変換
implicit def toQuantity(value: Int):Quantity = new Quantity(value)
}
class Quantity(value: Int) { // value は GetterでGetさせない
require(value >= Quantity.Min, s"不正: ${Quantity.Min}未満")
require(value <= Quantity.Max, s"不正: ${Quantity.Max}超え")
def asString(messageFormat: MessageFormat): String =
messageFormat.format(value)
def canAdd(other: Quantity): Boolean = {
val added = addValue(other)
added <= Quantity.Max
}
def add(other: Quantity): Quantity = {
require(canAdd(other), s"不正:合計が${Quantity.Max}超え")
val added = addValue(other)
new Quantity(added)
}
def +(other: Quantity): Quantity = add(other)
private def addValue(other: Quantity): Int =
value + other.value
}
数量クラスの利用例
val q1 = new Quantity(10)
val q2 = q1 + 5 // QuantityにIntを加算できる
val q3 = new Quantity(5)
val q4 = q2 + q3 // Quantity同士を加算できる
println(s"q2 = ${q2.asString(format)}, q4 = ${q4.asString(format)}")
これで数量に関することは数量クラスに凝集させることができます。
あと、数量に関するテストもこのクラスのみでよく、大量のテストは不要です。
例えば数量をただの整数だと考えすべての実装でIntを使う。しかし値の範囲は0~100。すべての利用箇所でテストが必要に…。しかしIntを数量クラスに置き換えたら、数量に関するテストは数量クラスだけで済みます。仕組みを作るのは面倒だけど、この仕組みがあるからこういう問題を解決できるんだと思う
— かとじゅん@MHW復帰勢 (@j5ik2o) September 1, 2019
ドメインオブジェクトのGetterが必要になる場合はどうするか
前述したように都合よくGetterをなくせるとよいですが、以下の理由でGetterが必要になる場合があります。
1. リポジトリなどを使ってストレージに保存する場合。RDBではSQLを使って内部データをテーブル構造に変換しなければなりません
2. ドメインオブジェクトの内部データをレスポンスやビューに出力する場合。内部データをDTOなどに変換しなければなりません
ドメインオブジェクトとこれらの入出力層とのやりとりは、どうしても内部データが必要になります。
以下のように特定のパッケージからのみGetterを利用できるようにしてもよいですが、複数のパッケージからの利用には対応できないので限界があります。6
class Quantity(value: Int) {
private[interface] def getValue: Int = value
}
もう一つの方法はドメインモデルの根拠とドメインモデル貧血症の対策についての記事でも触れている方法で、breachEncapsulationOf
などの長いプレフィックスを付ける方法です。Getterを乱用するとコードが煩雑になることで、振る舞いを抽出する機会とする方法もあると思います。たとえば、アプリケーションサービスでこのGetterを乱用したらリファクタリングし、テーブルやJSONに変換する場合は妥協するという具合です。こっちの方が現実的かなと思います。7
class Quantity(value: Int) {
// ...
def breachEncapsulationOfValue: Int = value
// ...
}
I/O都合以外でGetterを利用した場合は、ドメインオブジェクトにロジックを移すしてメソッド化することを考えます。長い名前を使って煩雑さを覚えたら、それをリファクタリングのよい機会にします。
val formatString = "%0d".format(q1.breachEncapsulationOfValue)
上記を以下にリファクタリングする
val formatString = q1.toFormatString("%0d")
ここまでしなくても、レビューでチェックする方法もあると思いますが、忙しいとどうしても楽な方に流れるので…。
他によい方法があれば教えてください。
既存コードでの改善方法
以下のようにやれば、既存コードでも可能だと思います。ご参考までに。
内部データを暴露するGetterをなくしてコンパイルするとコンパイルエラーになる。Getしてごにょってるビジネスロジックを発見。流出したロジックをドメインオブジェクトに移してメソッド化。リポジトリやJSONに変換する際は長いプレフィックス持つGetterを用意して使う。これやると確実に整理できる
— かとじゅん (@j5ik2o) August 21, 2019
まとめ
- ドメイン知識が伴う計算は、ドメイン固有型を定義し、そこにメソッドとして凝集させる
- I/Oを行うときのみGetterを利用する
併せて読みたい:
Getter/Setterを避けて役に立つドメインオブジェクトを作る - かとじゅんの技術日誌
追記
8/23 コメントからヒントを得たので追記:
Scalaだとunapply
(抽出子)を使って内部データを抽出する方法もあります。ご参考までに。8
val q1 = new Quantity(10)
val Quantity(value) = q1 // Quantity.unapply で 内部データを抽出
println(value)
object Quantity {
val Min = 1
val Max = 100
def unapply(self: Quantity): Option[Int] = Some(self.getValue)
// コンパニオンオブジェクトからは特権があるのでコンパニオンクラス側の非公開メンバーにもアクセスできる
}
class Quantity(value: Int) {
require(/** ... */)
require(/** ... */)
private def getValue: Int = value
// ...
}
8/24 社内のメンバーから意見もらったので追加:
アプリケーションサービス内でドメインオブジェクトのGetterを使っていたら、Linterで警告するという方法もよさそう。
Scalaであればscala-styleやwart-removerなどを使えば簡単そうです。たぶん、拡張を書いたほうがはやそう。
https://github.com/scalastyle/scalastyle/blob/master/src/main/scala/org/scalastyle/file/RegexChecker.scala
を参考に実装するとか。
wart-removerでもそんなに難しくなさそう。
https://tanishiking24.hatenablog.com/entry/intro-wartremover
-
ユースケースクラスと呼ばれることもあります ↩
-
凝集という難しい言葉を使いましたが、集中させるや、集約させるというイメージでもよいと思います ↩
-
経験上の話ですが、不変条件を表明する設計スタイルでは、脆弱性診断において致命的な脆弱性を発見されたことがほとんどありません。脆弱性診断の企業側からも不変条件を表明するスタイルを推奨しているそうです。 ↩
-
この例ではインターフェイスアダプタ層のパッケージから許可しているが、パッケージ名とはいえドメインにI/Oに関連知識が入り込むのはいかがなものか… ↩
-
この方法はEricさんのプロジェクト Time and Moneyでも行われていた手法です。 ↩
-
内部データをGetする方法が違うだけですが、費用対効果はどちらがいいか。こっちのがシンプルかもしれない… ↩