【読書メモ】ドメイン駆動設計入門
2.3 値オブジェクトにする基準
どこまで値オブジェクトにするか?
FullNameクラスを用意した時、さらに姓と名それぞれ値オブジェクトに分けるかどうか。判断基準として筆者は「そこにルールが存在しているか?」と「それ単体で取り扱いたいか?」という点を重要視している。例えば姓に文字数制限や使用できる文字に制限があるばあいは、値オブジェクトとして管理する。
値オブジェクトにすべきと判断したなら大胆に!
2.5 値オブジェクトを採用するモチベーション
値オブジェクトを採用するモチベーションは以下の通り
- 表現力を増す
- 例えばシリアルナンバーのような複数の構成要素からなっている番号を考えた際に、プリミティブな値だと何も情報が得られないが値オブジェクトにすることでどのような構成(例えば製造拠点や製造年月日など)で構成されているかを読み手に伝えることができる。
- 不正な(もしくは、誤った)値を許さない
- 値オブジェクトとしてルールを定義しておけば、そのルール外の値や人為的な誤りを防ぐことができる。例えばUserクラスのオブジェクトが代入されるはずなのに、Bookクラスのオブジェクトが代入されてしまうようなミスを防ぐことができる
- ロジックの散在を防ぐ
- ルールは一箇所にまとめよう
値オブジェクトのコンセプトは「システム固有の値を作ろう」という単純なもの
ドメインの知識をコードに落とし込むドメイン駆動設計の基本パターンである
3.2 エンティティの性質について
エンティティと値オブジェクトの比較
エンティティの性質には値オブジェクトの性質を真逆にしたような性質もある。
値オブジェクト | エンティティ |
---|---|
不変である | 可変である |
交換が可能である | 同じ属性であっても区別される |
等価である | 同一性により区別される |
値オブジェクトは不変の性質が存在するため代入(交換)によって変更を表現する。
一方、エンティティは可変(例えばユーザー属性が可変であるように)である。
さらに、エンティティは同じ属性であっても区別される(同姓同名でも人として区別されるように)
3.3 エンティティの判断基準としてのライフサイクルと連続性
ライフサイクルが存在し、そこに連続性が存在するかどうか(例えばユーザー)
見る立場によってエンティティなのか、値オブジェクトなのか分かれる場合もある。
例えば、10円硬貨を考えた場合に
- 利用者(つまり一般人)からしたら、どの10円硬貨も同じ10円硬貨として区別されない(交換可能)
- ⇨ 値オブジェクトが適切
- 造幣局にとっては、10円硬貨ごとに固有の識別番号が振られ管理されている(交換不可能)、硬貨としてのライフサイクルも存在する
- ⇨ エンティティが適切
4.1 サービスが指し示すもの
ドメイン駆動設計で使われる「サービス」には大きく分けて2種類ある
一つは「ドメインのためのサービス(ドメインサービス)」、もう一つは「アプリケーションのためのサービス(アプリケーションサービス)」
4.2 ドメインサービスとは
ドメインサービスには値オブジェクトやエンティティに記述すると不自然になるようなふるまいを記述する。
不自然なふるまいとは、ユーザーオブジェクトにDBに自身の重複が存在しないか確認させたり、輸送拠点のオブジェクトに輸送させたり(本書で輸送モデルの話が出たので)と責務の範疇を超えた行いをさせる、など
そのような不自然なふるまいをドメインサービスに記述することで、不自然さを解消する
ドメインモデル貧血症
しかしながら、何でもかんでもドメインサービスに移してしまうと、エンティティや値オブジェクトといったドメインオブジェクトは単なるデータを保持するだけで他に情報がない「無口なオブジェクト」となってしまう。そうならないようにも、ドメインサービスに移すべき対象は「不自然なふるまい」に限定することが重要である。(例えばUserNameが10文字以内である、といったルールは値オブジェクトのルールになるのでそのまま記述しておく)
4.4 エンティティや値オブジェクトと共にユースケースを組み立てる
それでは^に書いたように、そのままユーザー重複確認をユーザードメインサービスに記述してしまうとそのドメインサービスは特定のデータストア操作に依存してしまう事になるので柔軟性に欠けた設計となってしまう。また、そういったデータストアの操作を混ぜてしまうと読み手にとってもドメインサービスの主旨の理解の妨げになってしまう。
従って、ドメインサービスにはデータストアといったインフラストラクチャが絡まないドメインオブジェクトの操作に集中させることが大切。そのためにも、リポジトリの存在が重要となる。
5.1 リポジトリとは
リポジトリを用意することのメリット
リポジトリはデータを永続化(保存)し再構築(復元)するといった処理を抽象的に扱うためのオブジェクト。データの永続化と再構築を行う際にリポジトリを経由させて行うことでソフトウェアに柔軟性を与え、ドメインサービスはよりドメインに集中した純度の高いロジックを含ませることができる。(データストアがRDBなのかNoSQLなのかは関心外)
逆に、ロジックが特定のインフラストラクチャ技術に依存することはソフトウェアを硬直化させることにつながってしまう。
5.3 リポジトリのインターフェース
インターフェースと実態
リポジトリはインターフェース(抽象型)として定義される。(本書ではIuserRepository)
インターフェースのメソッドが呼び出されると、インターフェースは実態クラス(本書ではUserRepository)の同メソッドを呼び出す。実態クラスに特定のデータストアに依存した処理を書く。
もしデータストアが変更になる場合も、この実態クラスを書き直すだけで済む
注意したいのはリポジトリの責務はあくまでも「永続化」と「再構築」である
なので「永続化」と「再構築」以外の例えばユーザーの重複確認、などはリポジトリの責務外(ドメインルールの近くに書くのが適切)
6.1 アプリケーションサービスとは
アプリケーションサービスを端的に表現するならユースケースを実現するオブジェクト
例えば、ユーザー機能のアプリケーションであればユーザー情報の登録や退会(いわゆるCRUD)といったふるまい(ドメインオブジェクトのふるまい)が定義される。
ドメインオブジェクトを公開する危険性とその対策
ユーザー登録をアプリケーションサービスの例を考えてみる。登録処理の戻り値を登録したユーザーオブジェクトにしてしまう(ドメインオブジェクトを公開する)と以下のリスクが生じる。
- アプリケーションサービス以外のオブジェクトがドメインオブジェクトにたいする操作が可能となる
- ドメインオブジェクトに依存する箇所が多発してしまう
- 本来ドメインオブジェクトのふるまいはアプリケーションサービスで定義するべきなのに、他のオブジェクトでもドメインオブジェクトのふるまいの定義が可能となってしまう(境界が曖昧に)
こうならないようにするための対策としては以下が候補として挙げられる(あくまでも開発ポリシーに依る)
- 開発チーム内でドメインオブジェクトの呼び出し制限ルールを設ける
- ドメインオブジェクトを公開しない
1の場合は、強制力が小さく対策としては脆いものであることから筆者は2の「ドメインオブジェクトを公開しない」対策を推奨している。
さらに、ドメインオブジェクトを公開しない代わりにオブジェクト->オブジェクトデータ(オブジェクトのふるまいを定義することは不可能)に変換するDTO(Data Transfer Object)を設ける
DTOに対するデータの移し替え処理はアプリケーションサービスの処理上に記述する
そうすることで、アプリケーションサービスの戻り値がドメインオブジェクトから単なるデータとなり汚染が防げる。
また、DTOによるデータ変換処理は一箇所にまとめたほうがオブジェクトのパラメータが変更された時にも反映が楽になる。その一箇所にまとめる戦術として、ドメインオブジェクトを受け取るDTOのコンストラクタを用意しておくと良い。
以下のようなイメージ
// ユーザーオブジェクトをユーザーデータに変換する
var userData = new UserData(user)
6.3 ドメインのルールの流出
ドメインルールはアプリケーションサービスに書かない
アプリケーションサービズにはドメインルールは書かず、あくまでもドメインオブジェクトの「タスク調整」に徹するべき。
ユーザーサービスの例を挙げると、「ユーザーの重複確認」はドメインの大切なルールといえる。
ユーザー登録時や更新時にそのルールが守られているか確認する。
その重複禁止のルールが仮に「同一ユーザーネームを禁止」から「同一メールアドレスを禁止」に代わった場合を考える。もしユーザー作成や更新といったドメインのふるまいが定義されているアプリケーションサービスにそのルールが書かれている場合、ルールが変更されたと同時に各アプリケーションサービスを修正しなければならない。これを防ぐにはドメインルールはドメインサービスに書く必要がある。
以下の場合、ドメインルールが変更された場合アプリケーションサービスA,B,Cそれぞれに手を加えないといけない
それを防ぐためにドメインサービスにドメインルールは書きましょう
6.4 アプリケーションサービスと凝縮度
凝縮度はモジュールの責任範囲がどれだけ集中しているかを測る尺度。
凝縮度を高めるとモジュールが一つの事柄に集中することになり、堅牢性・信頼性・再利用性・可読性の観点から好ましいとされる。
凝縮度を測る一つの方法にLCOM(Lack of Cohesion in Methods)があり、同一クラス内のインスタンス変数は全てのメソッドで使われるべきという考えに基づいた計測方法である。
例えば、ユーザーのアプリケーションサービス内が以下の構成であったとする
この場合、ユーザー退会するDeleteではドメインサービスのオブジェクトであるuserServiceが使われていないので、LCOMの観点では凝縮度を低下させる要因になっている。
凝縮度を高める(全てのインスタンス変数)ためにそれぞれのユースケースであるRegisterとDeleteを別々のサービスクラスとして分割する
あくまでも凝縮度の観点はコードを整理する一つのヒントとして役立つものであり絶対的な指標ではない。
6.6 サービスとは何か
ドメイン・アプリケーションサービスの概念の再整理
サービス | 抽象的な説明 | 具体例 |
---|---|---|
アプリケーションサービス | アプリケーションを 成り立たせるための機能 ユースケースの集まり |
ユーザー登録機能や退会機能 |
ドメインサービス | ドメインの知識(ルール)を 表現したオブジェクト |
ユーザーの重複は許されない |
それぞれの違いを意識して、区別して管理することが大切
アプリケーションサービスにドメイン知識(ルール)が記述されないよう(漏れないよう)に気をつけましましょう
7.2 依存とは
ObjectAがObjectBを参照していると、ObjectAはObjectBに依存していることになる
もしApplicationServiceがデータストアの操作が記述されている具象クラスRepositoryに直接依存してしまっている場合は、すなわちApplicationServiceが特定のデータストアに結びついてしまうという問題が発生する。
そうするとデータストアに変更が加わった場合にApplicationServiceにも影響が及んでしまう。
そうならないように間に抽象型であるインターフェースIRepositoryを設ける。
抽象型は依存の矢印の向き先に位置するため、以下のような依存関係をつくることができる。
こうすることによって、ApplicationServiceをデータストアへ結びつかないように制御することができる。この抽象型を用いて依存関係を制御することを「依存関係逆転の原則」と呼ぶ。
依存関係逆転の原則は次のように定義されている
- 上位レベル・下位レベルどちらのモジュールも抽象に依存すべき
- 実装の詳細が抽象に依存すべき
※レベルとは入出力からの距離を指す(高レベル:ApplicationService ↔️ Repository:低レベル)
主導権は高レベルのモジュール
重要なドメインのルールが含まれるのはいつだって高レベルなモジュール。高レベルなモジュールは低レベルなモジュールを利用するクライアントであり、主導権は常にクライアントにある。低レベルなモジュールの変更の影響が、主導権を握る高レベルなモジュールにあってはならない。
7.4 依存関係をコントロールする
パターンとして
- Service Locator パターン
- IoC Container パターン
がある
いまいち本書を読んでもピンとこないポイントもあったので、他に調べるとこちらのブログがわかりやすかった。
IoC
そもそもIoCとは"Inversion of Control" の略で「制御の反転」を意味し、Service Locator と IoC Containerより上位の概念にあたる。
インターフェースを間に挟み外から具象クラスを与える。おそらく上記の「依存とは」セクションで触れた依存関係の逆転と同じような意味を持っているのだろうと理解している。
Service Locator パターン
Service LocatorパターンはService Locatorと呼ばれるSingletonなFactoryで、インスタンスをキャッシュし使う人(本書はService)が呼び出す。Service Locatorは具象クラスをよしなに外から渡してくれる。
しかしながら、依存関係が外部から見えづらくなる、テストの維持が難しくなるといった観点から安置パターンとされておりほとんど使われないパターンとのこと。
IoC(≒DI) Container パターン
本書でもDI Container の説明だと思うので、DIの説明から
先程のService LocatorパターンはServiceがService Locatorを呼び出したが、DI Containerの場合は外からServiceに依存関係を注入する(具象クラスを渡す)
注入方法には様々な方法(コンストラクタ、セッター、メソッドなど)があるがこれらの多様な注入のパターンに対応しているのがDI Containerと呼ばれるもの。
9.2 複雑な生成処理をカプセル化しよう
本来であれば初期化はコンストラクタの役割。コンストラクタは単純である必要がある。
コンストラクタが単純でなくなった時にファクトリを定義する
ファクトリを定義する一つの指標として「コンストラクタ内で他のインスタンスを生成するか」がある。
もしコンストラクタがインスタンスを生成している場合は、オブジェクトが変更される際にコンストラクタも変更しなくてはならなくなる恐れがある。闇雲にファクトリを使ってインスタンスを生成するのではなく、ファクトリを導入するかべきか検討する習慣を身につけることが大切。
ファクトリは生成処理をカプセル化すること。
カプセル化によってロジックの意図を明確にしながら、柔軟性を確保できる。
10.3 ユニークキー制約による防衛
データベースの任意のカラムの値が重複しないように、整合性を保つための手段としてユニークキー制約が挙げられる。では、データベースのユニークキー制約を設定すればアプリケーションコードの重複確認作業は不要かといえば、そうではない。
なぜなら、リレーショナルデータベースのユニークキー制約のみに頼ることはすなわち、特定の技術基盤に依存することを意味するからである。発生するデメリットとして以下が挙げられる。
- 読み手が重複に関するルールを読み取ることができない
- もし重複を許さない別のカラムが変更された場合、気付くことができないリスクがある
従って、仮にユニークキー制約をデータベースに設定したとしてもアプリケーションコードで重複を制御するべき。
ユニークキー制約はルールを守る主体ではなく、セーフティネットとして活用されるべき機能である。
10.4 トランザクションによる防衛
データの生合成を保つために利用される一般的な手段としては、データベースのトランザクション機能が挙げられる。トランザクションを扱うにはデータベースコネクションを使い回す必要があるが、データベースコネクションを扱ったトランザクションを直接サービスやリポジトリに書くのは好ましくない。なぜなら、データベースコネクションという特定の技術に依存してしまうことになるからである。
トランザクションスコープ
そこで活躍するのがトランザクションスコープという機能である。トランザクションスコープはトランザクションを行う範囲を定義したものであり、その内部で例外が発生した場合はロールバックが実施される。
トランザクションスコープを使うことで、特定の技術基盤による依存から解放される。技術基盤を変更した場合も、トランザクションスコープの機能が有効であればApplicationServiceを変更する必要もなくなる。
ScalikeJDBCでいうとlocalTx blockなどがトランザクションスコープに該当する
12.1 集約とは
集約は関連するオブジェクト同士を線で囲う境界として定義される。
例えば、ユーザーとユーザーが加入できるサークルそれぞれの集約を図で表すと以下のようになる。
またユーザー集約とサークル集約にはそれぞれ集約ルートという概念が存在する。
各集約内のオブジェクトに対する操作は全てこの集約ルートに依頼する必要がある。
例えばユーザー名を変更したい時は以下のように直接対象に対して操作するのではなく
user.Name = userName
以下のようにユーザー集約のルートであるユーザーオブジェクトに依頼する。
user.ChangeName(userName)
サークルにメンバーを加入させる、というケースを考えた際も集約ルートであるサークルに依頼する。
以下のようにサークルメンバーに加入メソッドを生やすのではなく
circle.Members.Add(member)
サークルメンバーを保持するサークルオブジェクト自身に加入を依頼する
circle.join(user)
メソッドの呼び出し方を見てもより直感的な書き方であるし、
例えば定員が30人以内であることなどの、サークルのドメインルールが持つルールを常に呼び出すことも可能となりルールを保つことができる(不変条件を維持)
オブジェクト指向プログラミングではこのように、外部から内部のオブジェクトに対して直接操作するのではなく、それを保持するオブジェクトに依頼する形を取る。(車を方向転換する際に、車のタイヤに指示するのではなく、車自身に依頼するように!)そうすることで直感的に不変条件を維持することが可能となる(デルメルの法則)
12.2 集約をどう区切るか
異なる集約の操作を混ぜないための工夫
もし異なる集約の処理を混ぜてしまうと、そのリポジトリが異なる集約による処理で汚染されてしまったり、同様の処理コードを複数のリポジトリに点在させてしまったりとデメリットがある。
それを防ぐために、そもそも異なる集約のインスタンスを渡さないという手がある。
例えばサークル集約中にユーザーインスタンスを渡さない、仮にユーザーというエンティティの個を表す「識別子」を渡すようにすれば必然的にサークル集約中ではユーザーインスタンスが持つメソッドを呼び出すことはできないし汚染されることはない。
また、集約の大きさはできる限り小さく保つべきであるし、同一トランザクション中に複数の集約の処理を含めるべきではない。なぜなら複数の集約にまたがるトランザクションや巨大な集約のトランザクションは、広範囲のロックを引き起こしうるからである。
13.1 仕様とは
リポジトリを用いる複雑で重要なドメインルールは仕様に任せよう
Userなどエンティティにリポジトリを持たせることは好ましいことではない。なぜなら、リポジトリはドメイン由来のものではないからである。そういった時に代わりの手段として仕様と呼ばれるオブジェクトを利用した解決方法がある。仕様にリポジトリを渡し、仕様はオブジェクトの評価のみを行う。複雑な評価手順(ドメインルール)を仕様に切り出すことでオブジェクト自身の趣旨を明確にすることができる。
例えば、以下のようにサークルの定員条件が複雑になった場合(ユーザーの属性などを用いるなどユーザーリポジトリが必要になるような場合)などにサークルクラスに直接書くのではなく、仕様を用いる。
// 仕様をインスタンス化する(ユーザーリポジトリを渡す)
var circleFullSpecification = new CircleFullSpecification(userRepository);
// ユーザーの属性などを考慮してサークル定員未満か否かを評価する
if (circleFullSpecification.IsSatisfiedBy(circle))
{
throw new CircleFullExeception(circleId);
}
このように重要なルールを仕様オブジェクトとして定義し、リポジトリに引き渡せば重要なルールがリポジトリの実装クラスに漏れ出すことを防げる。
14.1 アーキテクチャの役目
アーキテクチャは方針であり、何がどこに記述されるべきかといった疑問に対する回答を明確にしてくれる。その方針に従うことで開発者は「何をどこに書くのか」に振り回されないようになるし、結果としてドメインが無秩序に点在することを防げる。
これは開発者がドメイン駆動設計の本質である「ドメインを捉え、うまく表現する」ことに集中するために必要なことである。
14.2 アーキテクチャの解説
ドメイン駆動設計にとってはドメインが隔離されることが重要であり、いずれかのアーキテクチャに従わなければならないというわけではない(アーキテクチャは方針!)。またアーキテクチャに従ったからといって、ドメイン駆動設計を実施したことにもならないことは注意したい。
レイヤードアーキテクチャ
レイヤードアーキテクチャを構成する4つの層の内訳は以下の通り。
↑上位 ↓下位
- プレゼンテーション層(ユーザーインターフェース層)
- アプリケーション層
- ドメイン層
- インフラストラクチャ層
原則として、上位のレイヤーは下位のレイヤーに依存することは許されるが逆は許されない。
それぞれの役割の概要は以下の通り
層 | 役割 | 本書で登場したクラス |
---|---|---|
プレゼンテーション層 | ユーザーインターフェースとアプリケーションを結び助ける。主な責務は「表示と解釈」。システム利用者の入力をアプリケーション層へ伝えられるように解釈し、システム利用者が分かるように表示を行う | UserController |
アプリケーション層 | ドメインの住人を取りまとめる層。ドメインオブジェクトの直接のクライアントとなり、ユースケースを実現するための進行役となる。 | UserApplicationService |
ドメイン層 | 最も重要な層。ドメインの振る舞いや表現を定義する。ドメインオブジェクトをサポートする役割を持つファクトリやリポジトリのインターフェースもこの層に含まれる。 | User, UserService, IUserRepository |
インフラストラクチャ層 | 技術基盤へのアクセスを提供する層。 | UserRepository |
本書の実装例などの構成もレイヤードアーキテクチャがベースとなっている。