ドメイン知識が漏れるとは何なのか
先日プラハチャレンジのメンターセッションで「ドメイン知識が漏れるってどういうことなの?」という話になったので、その際に話したことをまとめてみようと思いました。
こちら本日のメイン、ドメイン知識が漏れまくっているコードです:
class Person { // (ドメイン)
public age: number
public constructor(age: number) {
this.age = age
}
}
class Usecase1 { // (アプリケーションサービス層に属するクラス。コントローラー層のメソッドの1つと捉えていただいてもOKです)
do(p: Person) {
if (p.age > 18) {
// 18歳以上なら登録できる!
}
}
}
class Usecase2 { // (アプリケーションサービス層に属するクラス。コントローラー層のメソッドの1つと捉えていただいてもOKです)
do(p: Person) {
if (p.age < 48) {
// 48歳未満なら好きな画像も保存できる!
}
}
}
class Usecase3 { // (アプリケーションサービス層に属するクラス。コントローラー層のメソッドの1つと捉えていただいてもOKです)
do(p: Person) {
if (p.age == 100) {
// 100歳の方にはamazonギフト券をプレゼント!
}
}
}
この記事の対象読者
- このコードを見て「ドメインモデル貧血症だわ」「これはドメインロジックが散乱するからだめだ」とすぐに分かった方はこの記事を読み進めても特に得るものはないと思います
- 逆に「え、なんでこれがダメなの?」「こういうコード普段から書いてる!」「ドメインロジックが漏れてるってどういうこと?」と感じた方にはこの記事が役に立つかもしれません!
ざっくりコードの概要説明
このwebアプリケーションはPersonの年齢に応じて様々なドメインロジックが要求されます:
- 18歳以下の人は登録できないようにしたい
- 48歳未満の人は好きな画像も保存できるようにしたい
- 100歳ジャストの方にはamazonギフト券をプレゼントしたい
ドメインロジックって何?と迷われた方は、このWEBサービス/事業のルールという言葉に置き換えていただいても差し支えないと思います。なので表題の「ドメインロジックが漏れる」は「WEBサービス/事業のルールが漏れる」というイメージです。「漏れる」については後述します。
早速、このコードの何が問題なのか
class Person { // (ドメイン)
public age: number // <- これが問題
public constructor(age: number) {
this.age = age
}
}
ageはpublicなので、アプリケーションのどこからでも自由にpersonのageを変更できるし、アプリケーションのどこからでも自由にageを取得できます。この「どこからでも」というのが諸悪の根源です。
どこからでも値を取得できると何がまずいのか?
「どこからでもageを変えられるのはマズそう」というのは多くの方が感覚的に共有できると思うのですが、意外と見落とされがちなのが「どこからでもageを取得できる」ことが引き起こす問題です。
一番大きな問題は呼び出し側がageを使って自由にドメインロジックを書けることです。
まさに今回のアプリケーションはそうなっていますよね。呼び出し側が自由にドメインロジックを書いています:
- Usecase1にはpersonから取り出したageに応じて「18歳より上ならxxx」というドメインロジックが書かれている
- Usecase2にはpersonから取り出したageに応じて「48歳未満ならxxx」というドメインロジックが書かれている
- Usecase3にはpersonから取り出したageに応じて「100歳ならxxx」というドメインロジックが書かれている
これにはいくつかの問題があります:
矛盾したロジックが生まれやすい
class Usecase1 {
do(p: Person) {
if (p.age > 18) {
// 18歳より上なら登録できる!
}
}
}
.
.
.
class Usecase408 { // csv読み込みでユーザーを登録できるようにする
do(p: Person) {
if (p.age > 20) {
// 20歳より上なら登録できる!
}
}
}
アプリケーションが成長して400個ぐらいユースケースが存在する状態でcsv経由のPerson登録機能を追加したものの、既存ロジックを見落としてしまい「20歳以上なら登録できる」という矛盾するロジックを実装してしまいました。被疑者は「だって仕様書には 大人しか登録できない って書いてあったんだもん...!大人って20歳でしょ!?」と供述しています。
こうして年齢制限が18歳なのか20歳なのか分からないアプリケーションが誕生してしまいましたとさ。
ロジックが重複しやすい
class Usecase102 {
do(p: Person) {
if (p.age > 18) {
// もし登録可能なユーザーなら身分証を提出してもらう
}
}
}
class Usecase219 {
do(p: Person) {
if (p.age > 18) {
// もし登録可能なユーザーならhogeする
}
}
}
class Usecase18 {
do(p: Person) {
if (p.age > 18) {
// もし登録可能なユーザーならfugaする
}
}
}
新しい機能を追加するたびにうん100個も存在するユースケースを全部確認するのは現実的ではないですよね。誰だってサボりたくなります。そうするとこんな風にロジックが至る所で重複してしまうので、
「登録の条件を変えて、名前が20文字以上だったら登録できるようにするから」
と言われたらど修正箇所が沢山あって大変ですよね。一つの変更理由に対して変更箇所が無数に存在するのは典型的なコードスメル(やばそうな気配)です。
用途をコントロールできない
「どれだけ変更箇所が増えても平気です。私にはVSCodeがあるから変えるべき場所はすぐ分かります!」と思うかもしれませんが、全てのperson.ageが必ずしも登録可否の判定に使われているとは限りません:
// ログ用途にperson.ageを使っている
console.log(person.age)
// person.ageを返してるだけ
return person.age
// これが本当に登録可否の判定に使われているかは周辺コードを読まないと分からない
// もしかしたら全く違う判定に使われているかもしれない
if (person.age > 18)
// これも...
if (person.age > 36)
// これも...
if (person.age > 18)
アプリケーション全体で様々な用途で100回ぐらいperson.ageが呼び出されていたら、それを全部確認して「ここでperson.ageは登録可否の判定に使われているのだろうか?YESなら変えなきゃいけないけど、NOなら変えちゃいけない...」と丁寧に一つ一つ確認しなければいけません。地獄のはじまりです。僕なら絶対間違えちゃう。
このように、プロパティをpublicにした瞬間その利用方法は呼び出し側に委ねられるので、用途をコントロールできなくなってしまいます。どこでどんな風に使われているのか分からないと確認範囲も広がってしまうし、そこを変更すべきか判断するのが難しくなってくる。保守しづらいアプリケーションの爆誕です
これがドメイン知識が漏れている状態
このようにドメインロジックがアプリケーションの至るところに書かれてしまっているのが「ドメイン知識が漏れている」と言われる状態で、それを引き起こしやすいのがpublicなgetterだ、という内容をここまで解説してきました。
なぜ「漏れている」なのかというと、ドメインロジックをドメイン層(今回だとPersonクラス)に保っておきたいのに、それ以外の層に存在しているから(漏れ出しているから)というニュアンスですね。染み出している、とか言われることも。
Personクラスはドメインモデル貧血症に陥っている、とも言えます。簡単にいうとPersonクラスがただのjsonと大差ない情報の入れ物と化していて、Personクラス自体が何もドメイン知識を表現していない状態です。
ドメインモデル貧血症を避けたい理由は先述の通りで、
- サービス/事業を実現するためにドメインロジックはどこかに書かなければいけない
- ドメイン層に書かないとなると、どこか他のところに書くしかない
- ドメインロジックがアプリケーション全体のどこに書かれているのか分からなくなる
- メンテナンスしづらい...
解決方法
プロパティをprivateにして専用のメソッドを定義するのが第一歩です:
class Person {
private age: number // <- publicをprivateに変えた
public constructor(age: number) {
this.age = age
}
}
ageをprivateにすることで、これまでの呼び出し箇所ではperson.ageを参照できずエラーが発生するので、それぞれの用途に応じたメソッドをPersonに追加していきます。例えば登録可否を判断したいのであれば
class Person {
private age: number
public constructor(age: number) {
this.age = age
}
public canRegister() { // 登録できるかどうか判断するメソッドを追加
return this.age > 18
}
}
画像を保存できるかどうか、100歳ならギフト券を送る、みたいなメソッドもPersonに増やしていきます
class Person {
private age: number
public constructor(age: number) {
this.age = age
}
public canRegister() { // 登録できるかどうか判断するメソッドを追加
return this.age > 18
}
public canSaveImage() { // 画像を保存できるかどうか判断するメソッドを追加
return this.age > 48
}
public shouldSendGift() { // ギフト券を送るべきかどうか判断するメソッドを追加
return this.age === 100
}
}
今までperson.ageを利用していた側はこんな風に変化します:
class Usecase1 {
do(p: Person) {
if (p.canRegister()) {
// 登録できる!
}
}
}
class Usecase2 {
do(p: Person) {
if (p.canSave()) {
// 好きな画像も保存できる!
}
}
}
class Usecase3 {
do(p: Person) {
if (p.shouldSendGift()) {
// amazonギフト券をプレゼント!
}
}
}
person.ageを一切参照しておらず、ドメインロジックが綺麗さっぱり無くなりました。
元々起きていた問題を振り返ってみると:
-
ドメインロジックが矛盾しやすいか
Personのageに関するロジックは全てPersonクラスに集約されたので、矛盾を防ぐために確認しなければいけないファイルは1つだけに減りました -
ドメインロジックが重複しやすいか
Personのageに関するロジックは全てPersonクラスに集約されたので、重複を防ぐために確認しなければいけないファイルは1つだけに減りました -
用途をコントロールできるか
Personのageを呼び出し側が取り出すことはできなくなったので、用途はPersonクラスが把握しています
こうすることでドメインロジックがドメイン(Person)に閉じ込められて、ドメイン知識が漏れ出していない状態が実現できました。やったね!
イメージとしては、アプリケーションの中でPersonのageが必要になった時、ageをpublicにして「どこでも誰でも自由に使っていいよ!」と呼び出し側に任せるのではなく、「呼び出し側さんはageをどういう用途で使うのでしょうか?ふむふむ承知しました、ではその用途を満たしたメソッドを作りますから、そちらを呼んでください」といった具合に必要なロジックを実装した上で結果だけ返してあげる感じでしょうか。
ただ「なるほど!ageに関わる全ての処理はPersonにまとめればいいんですね!」と言われるとそうではなく、あくまでドメイン知識に関する処理をまとめるのが味噌で、「ドメイン知識か否か」の判断が大事なのですが長くなるので必要に応じて別の記事で書いてみようと思います。
またドメイン知識だとしてもageに関わることを何でもかんでもPersonにまとめるとそれはそれでPersonの肥大化を招くためドメインサービスにまとめたり仕様オブジェクトにまとめたり他のテクニックがあるのですが、これも長くなるので別記事へ。
また改善後の実装もまだまだ甘く、たとえば「画像を保存する前にpersonのcanSaveを呼び出して確認しなければいけない」と言うルールが依然としてユースケースに残っています。これを解消するためにはpersonそのものを受け取り、その使用方法について把握しているドメインサービスのメソッドがcanSaveを呼び出すように変える必要があるのですが、これもまた記事が長くなりすぎてしまうので割愛しています
おまけ:これって要はカプセル化じゃね?
そうなんです。ただカプセル化という単語そのものを知らないエンジニアは滅多に居ないと思うのですが、役に立つ形で扱われていないケースが意外と多い気がするんですよね。例えば以下のようなクラスを作って:
class Person {
private _age: number
public constructor(age: number) {
this._age = age
}
public get age() {
return this._age
}
public set age(age: number) {
this._age = age
}
}
「クラスのプロパティ(_age)をprivate化して直接参照できないようにしたからカプセル化できてるよ!」と言うのはカプセル化のメリットや目指す状態を考えないまま定義だけ暗記していると陥りがちな罠です。
「ドメイン知識がアプリケーションの至る所に点在するのを避けるためにカプセル化が役立つ」という用法を理解しておけば、上記のカプセル化もどきがドメイン知識の集約にはあまり役に立たないことが分かるのではないでしょうか。
何かを学ぶときは定義を覚えることはもちろん大切なのですが、それがどういう時に、どんな風に役に立つのかと具体的に沢山のパターンをイメージしておくと、より理解が深まるように思いました。
Discussion
🌀🌀🌀共感の嵐🌀🌀🌀
勉強になりました。ありがとうございます!
ご質問なのですが、複数のドメインに跨る情報をもとにドメインロジックを書く場合はどのようになるのがベストなのでしょうか。