読書メモ:ドメイン駆動設計入門
September 3, 2022
以下の本を読んだ。あとで見返したいところについてメモを残しておく。
- ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本
- https://www.shoeisha.co.jp/book/detail/9784798151687
Chapter 1:ドメイン駆動設計とは #
ドメインモデルとは何か - P.4 #
モデルとは現実の事象あるいは概念を抽象化した概念です。抽象は抽出して象るという言葉のとおり、現実をすべて忠実に再現しません。必要に応じて取捨選択を行います。何を取捨選択するかはドメインによります。
たとえばペンはどのような性質を抽出すべきでしょうか。小説家にとってペンは道具で、文字が書けることこそが大事な性質です。一方、文房具店にとってペンは商品です。文字が書けることよりも、その値段などが重要視されます。このことが指し示すのは、対象が同じものであっても何に重きを置くかは異なるということです。
人の営みは根本的に複雑です。ドメインの概念を完全に表現し切ることはとても難しいです。何かと制約の多いコードで表現するとなれば尚のことです。しかし、ソフトウェアがその責務を全うするために必要な情報に限定をすれば、コードで表現することも現実的になります。例えば物流システムにおいて、トラックは「荷運びできる」ことを表現すればそれで十分です。「エンジンキーを回すとエンジンがかかる」といったことまで表現する必要はありません。
こういった事象、あるいは概念を抽象化する作業がモデリングと呼ばれます。その結果として得られる結果がモデルです。ドメイン駆動設計では、ドメインの概要をモデリングして得られたモデルをドメインモデルと呼びます。
COLUMN:なぜいま、ドメイン駆動設計か - P.14 #
ドメイン駆動設計が提唱されたのは 2003 年頃です。IT という分野の進化は目覚ましく、最新の技術も十年過ぎれば陳腐化してしまうといったことは往々にして発生します。にもかかわらず昨今のシステム開発の現場において、ドメイン駆動設計という言葉を耳にする機会が増えたのには、いったいどのような背景があるのでしょうか。
ひと昔前はサービスをいち早く世に出すことこそがもっとも重要なこととされていたように感じます。そのためモデリングに重きを置き、開発の最初期にコストを支払うドメイン駆動設計は重厚で鈍重なものであると誤解され、敬遠されていました。
とにかく早くサービスを打ち出すことはほとんどミサイルのような片道ロケットに乗ることに似ています。打ち上げた後に帰ってこれないという欠点に目を瞑れば、システム開発の過酷な生存競争に勝つために取れる最良の選択肢でしょう。それに対してモデリングをしっかりと行い、長期的な運用を視野に入れた設計手法は飛行機を運用するようなことです。飛行機は片道ロケットと違って往復することは可能ですが、その速度は圧倒的に見劣りします。それにもかかわらず、なぜ私たちは片道ロケットの打ち上げ競争を辞めて、飛行機を安定運用したいと願うようになったのでしょうか。
ソフトウェアは変化するものです。ごく最初期の局所的な開発速度を優先したソフトウェアは、柔軟性に乏しく、変化を吸収しきれません。ソフトウェアに求められる変化に対応するために、開発者は継ぎはぎのような修正を重ねます。数年もすればソフトウェアは複雑怪奇な進化を遂げるでしょう。それでも時代の変化についていくために、開発者は辟易しながら継ぎはぎだらけの修正を積み重ねるのです。場当たり的な対応に嫌気を差した開発者たちが片道ロケットの打ち上げ競争ではなく、飛行機の安定運用を願うようになるのも想像に難くありません。救いを求めて手にしたものの中にドメイン駆動設計がありました。
ドメイン駆動設計はドメインと向き合うことで分析から設計、そして開発までが相互作用的に影響し合うよう努力を重ねることを求めます。ソフトウェアを構築する最初期においても一定の効果はありますが、その真価は変化に対応するときにこそ表れます。ドメイン駆動設計を取り入れてみた当時はさほど効果が見られなかったでしょう。ときが流れ、段々とドメイン駆動設計が認められてきたのは、偉大なる先人たちによって撒かれた種が芽吹いてきたからに他なりません。
プログラムは動かすだけなら簡単で、しかし動かし続けることは難しい代物です。システムを長期的に運用したいと願うのならば、安定的な飛行機の運用を願うのならば、ドメイン駆動設計をいまこそ学ぶべきでしょう。
Chapter 2:システム固有の値を表現する「値オブジェクト」 #
値の性質と値オブジェクトの実装 - P.19 #
値がもつ性質を学ぶことは値オブジェクトを知る上で重要な事柄です。ここで一度、値にはどのような性質があるのかしっかりと確認しておきましょう。
代表的な値の性質は次の3つです。
- 不変である
- 交換が可能である
- 等価性によって比較される
値オブジェクトはシステム固有の値の表現であり、値の一種です。値がもつ性質は値オブジェクトにそのまま適用されます。
値オブジェクトにする基準 - P.28 #
実をいうと、システムに登場する概念のうち、どこまでを値オブジェクトにするかは難しい問題です。単純にドメインモデルとして定義される概念であれば値オブジェクトとして定義されますが、そうでないときは迷いを生みます。
この正当性はコンテキストによるからです。どちらが正しいとも言い切れませんし、どちらが間違いとも言い切れません。
それでも「判断基準が欲しい」という気もちも理解できます。そこで参考意見としてここに筆者個人の判断基準を示しましょう。
ドメインモデルとして挙げられていなかった概念を値オブジェクトにすべきかどうかの判断基準として、筆者は「そこにルールが存在しているか」という点と「それ単体で取り扱いたいか」という点を重要視しています。
例えば氏名には「姓と名で構成される」というルールがあります。また本文で例示したように単体で取り扱っています。筆者の判断基準に照らし合わせると値オブジェクトとして定義されます。
では姓や名はどうでしょうか。いまのところ姓や名にシステム上の制限はありません。姓だけを取り扱ったり、名だけを利用するシーンもいまのところありません。筆者の判断基準からするとこれらはまだ値オブジェクトにしないでしょう。
Chapter 3:ライフサイクルのあるオブジェクト「エンティティ」 #
エンティティの性質について - P.49 #
エンティティは属性ではなく同一性によって識別されるオブジェクトです。エンティティの性質は次のとおりです。
- 可変である
- 同じ属性であっても区別される
- 同一性により区別される
エンティティの性質は値オブジェクトの性質を真逆にしたような性質もあります。
可変である #
値オブジェクトは不変なオブジェクトでした。それに比べてエンティティは可変なオブジェクトです。人々がもつ年齢や身長といった属性が変化するのと同じように、エンティティの属性は変化することが許容されています。
値オブジェクトは不変の性質が存在するため交換(代入)によって変更を表現していましたが、エンティティは交換によって変更を行いません。エンティティの属性を変化させたいときには、そのふるまいを通じて属性を変更することになります。
但し、すべての属性を必ず可変にする必要はありません。エンティティはあくまでも、必要に応じて属性を可変にすることが許可されているに過ぎません。可変なオブジェクトは基本的には厄介な存在です。可能な限り不変にしておくのはよい習慣です。
同じ属性であっても区別される #
値オブジェクトは同じ属性であれば同じものとして扱われました。エンティティはそれと異なり、たとえ同じ属性であっても区別されます。
同一性をもつ #
オブジェクトには属性が異なっていたとしても同じものとしてみなす必要があるものが存在します。それらはみな同一性により識別されるオブジェクトです。
値オブジェクトの比較処理ではすべての属性が比較の対象となっていましたが、エンティティの比較処理では同一性を表す識別子(id)だけが比較の対象となります。これにより、エンティティは属性の違いにとらわれることなく同一性の比較が可能になります。
COLUMN:セーフティネットとしての確認 - P.52 #
モデルを表現したオブジェクトの値がドメインのルールに適合しているかどうかは重要な問題です。したがってドメインのルールに違反するようなことは排除する必要があります。本文に登場した User オブジェクトはまさにそれを行っていて、異常な値(null や短すぎる名前)が引き渡されるとオブジェクトは例外を送出し、プログラムは終了します。
この例外はあくまでもセーフティネットとして機能する例外です。
したがって、例外が起こりうることを前提にするのではなく、その検査は事前に行うべきです。たとえばユーザの名前を変更するときに、新たなユーザ名が異常な値を取りうるのであれば、クライアント側で事前に検査をします。
エンティティの判断基準としてのライフサイクルと連続性 - P.58 #
値オブジェクトとエンティティはドメインの概念を表現するオブジェクトとして似通っています。であれば何を値オブジェクトにして、何をエンティティにするかという判断の基準が欲しいところです。ライフサイクルが存在し、そこに連続性が存在するかというのは大きな判断基準になります。
もしライフサイクルをもたない、またはシステムにとってライフサイクルを表現することが無意味である場合には、ひとまずは値オブジェクトとして取り扱うとよいでしょう。ライフサイクルをもつオブジェクトは生まれてから死ぬまで変化をすることがあります。正確さが求められるソフトウェアを構築するにあたって、可変なオブジェクトはその取扱いに慎重さが要求される厄介なものです。不変にしておけるものは可能な限り不変なオブジェクトのままにして取り扱うことは、シンプルなソフトウェアシステムを構築する上で大切なことです。
値オブジェクトとエンティティのどちらにもなりうるモデル - P.59 #
ものごとの側面は決してひとつだけとは限りません。それが全く同じ概念を指していても、システムによっては値オブジェクトにすべきときもあればエンティティにすべきときもあります。
たとえば車にとってタイヤはパーツです。特性に細かい違いはあるものの交換可能でまさに値オブジェクトとして表現可能な概念です。しかし、タイヤを製造する工場にとってはどうでしょうか。タイヤにはロットがあり、それがいつ作られたものであるかという個体を識別することは重要なことです。タイヤはエンティティとして表現する方が相応しいでしょう。
同じものごとを題材にしても、それを取り巻く環境によってモデルに対する捉え方は変わります。値オブジェクトにも、エンティティにもなりえる概念があることを認識し、ソフトウェアにとって最適な表現方法がいずれになるのかは意識しておくとよいでしょう。
Chapter 4:不自然さを解決する「ドメインサービス」 #
ドメインサービスとは - P.66 #
値オブジェクトやエンティティなどのドメインオブジェクトにはふるあいが記述されます。たとえば、ユーザ名に文字列を利用できる文字種に制限があるのであれば、その知識はユーザ名の値オブジェクトに記述されてしかるべきでしょう。
しかし、システムには値オブジェクトやエンティティに記述すると不自然になってしまうふるまいが存在します。ドメインサービスはそういった不自然さを解決するオブジェクトです。
ドメインサービスは値オブジェクトやエンティティと異なり、自身のふるまいを変更するようなインスタンス特有の上値を持たないオブジェクトです。
ドメインサービスの濫用が行き着く先 - P.70 #
エンティティや値オブジェクトに記述すると不自然なふるまいはドメインサービスに記述します。ここで重要なのは「不自然なふるまい」に限定することです。実をいうとすべてのふるまいはドメインサービスに記述できてしまいます。
ドメインサービスにすべてのふるまいを記述するとエンティティにはゲッターとセッターだけが残ります。
無思慮にドメインサービスへふるまいを移設することは、ドメインオブジェクトをただデータを保持するだけの無口なオブジェクトに変容させる結果を招きます。ドメインオブジェクトに本来記述されるべき知識やふるまいが、ドメインサービスやアプリケーションサービスに記述され、語るべきことを何も語っていないドメインオブジェクトの状態をドメインモデル貧血症といいます。これはオブジェクト指向設計のデータとふるまいをまとめるという基本的な戦略の真逆をいくものです。
すべてのふるまいはドメインサービスに移設できます。やろうと思えばいくらでもドメインモデル貧血症を引き起こせてしまいます。
ふるまいをエンティティや値オブジェクトに定義するべきか、それともドメインサービスに定義するべきか、迷いが生じたらまずはエンティティや値オブジェクトに定義してください。可能な限りドメインサービスは利用しないでください。
Chapter 5:データにまつわる処理を分離する「リポジトリ」 #
リポジトリとは - P.86 #
オブジェクトを繰り返し利用するには、何らかのデータストアにオブジェクトのデータを永続化(保存)し、再構築(復元)する必要があります。リポジトリはデータを永続化し再構築するといった処理を抽象的に扱うためのオブジェクトです。
オブジェクトのインスタンスを保存したいときは直接的にデータストアに書き込みを行う処理を実装するのではなく、リポジトリにインスタンスの永続化を依頼します。また永続化したデータからインスタンスを再構築したいときにもリポジトリにデータの再構築を依頼します。
リポジトリの責務 - P.87 #
リポジトリの責務はドメインオブジェクトの永続化や再構築を行うことです。永続化とは、インスタンスを保存し、復元できるようにすることです。
永続化というとリレーショナルデータベースを思い浮かべがちですが、永続化を行う具体的な技術基盤はそれに限りません。リレーショナルデータベースと一口にいってもさまざまな種類がありますし、それ以外にも単純にファイルにデータを保存する場合もあれば、NoSQL データベースを利用する場合もあります。
ドメインオブジェクトを再構築しようとしたとき、対象となるオブジェクトが見つからなかった場合には null を返却することで見つからなかったことを表現します。
重複チェックという目的を鑑みると Exists メソッドをリポジトリに実装するというアイデアが浮かぶこともあるでしょう。しかし、リポジトリの責務はあくまでもオブジェクトの永続化です。ユーザの重複確認はドメインのルールに近く、それをリポジトリに実装するというのは責務として相応しくありません。ユーザの重複確認はあくまでドメインサービスが主体となって行うべきです。
リポジトリに定義されるふるまい - P.108 #
リポジトリにはオブジェクトの永続化と再構築に関するふるまいが定義されます。
永続化に関するふるまい #
永続化のふるまいは永続化を行うオブジェクトを引数に取ります。したがって、対象の識別子と更新項目を引き渡して更新させるようなメソッドは用意しません。
// 悪い例:対象の識別子と更新項目を引き渡している
interface IUserRepository
{
void UpdateName(userId id, UserName name);
void UpdateEmail(userId id, Email email);
void UpdateAddress(userId id, Address address);
}
そもそもオブジェクトが保持するデータを変更するのであれば、それはオブジェクト自身に依頼すべきです。こういったコードは避けましょう。
同様にオブジェクトを作成する処理もリポジトリには定義しません。
他に、永続化に関係するふるまいとして挙げられるのはオブジェクトの破棄に関する操作です。ライフサイクルのあるオブジェクトは不要になったとき破棄されます。破棄を行うメソッドがリポジトリに定義されます。
// 具体例:破棄を行う
interface IUserRepository
{
void Delete(User user);
}
再構築に関するふるまい #
もっとも頻繁に利用される再構築のふるまいは識別子によって検索されるメソッドです。
// 具体例:識別子によって検索
interface IUserRepository
{
User Find(userId id);
}
基本的にはこの識別子による検索メソッドを利用しますが、たとえばユーザ名の重複が発生しているかを確認するためには全件取得する必要があります。そういったときには対象となる全オブジェクトを再構築するメソッドを定義します。
検索を定義する際にはそれに適したメソッドを定義します。
// 具体例:全件取得や検索
interface IUserRepository
{
List<User> FindAll();
User FindByUserName(UserName name);
}
Chapter 6:ユースケースを実現する「アプリケーションサービス」 #
アプリケーションサービスとは - P.114 #
アプリケーションサービスを端的に表現するならば、ユースケースを実現するオブジェクトです。それらのふるまいは実際にドメインオブジェクトを組み合わせて実行するスクリプトのようなふるまいです。
ユースケースを組み立てる - P.115 #
ユースケースからクライアントへ結果を返却するとき、結果となるオブジェクトとしてドメインオブジェクトをそのまま戻り値とするか否かの選択は、重要な分岐点です。
ドメインオブジェクトを公開する選択肢を選んだ場合、アプリケーションサービスの実装コードは比較的シンプルなものになります。しかし、これは同時にわずかな危険性をはらみます。アプリケーションサービスを利用するクライアントはドメインオブジェクトのふるまいを呼び出し操作できてしまうということです。
そこで筆者がお勧めするのはドメインオブジェクトを直接公開しない方針です。ドメインオブジェクトを非公開としたとき、クライアントにはデータ転送用オブジェクト(DTO、Data Transfer Object)にデータを移し替えて返却します。
// User クラスのデータを公開するために定義された DTO
public class UserData
{
public UserData(string id, string name)
{
Id = id;
Name = name;
}
public string Id { get; }
public string Name { get; }
}
DTO に対するデータの移し替え処理はアプリケーションサービスの処理上に記述されます。
public class UserApplicationService
{
private readonly IUserRepository userRepository;
// (...略...)
public UserData get(string userId)
{
var targetId = new UserId(userId);
var user = userRepository.Find(targetId);
var userData = new UserData(user.Id.Value, user.Name.Value);
return userData;
}
}
User インスタンスは外部に引き渡されないため、UserApplicationService のクライアントは User のメソッドを呼び出すことができません。
なお、外部に公開するパラメータが追加されたとき、コードを変更する必要があります。
public class UserApplicationService
{
private readonly IUserRepository userRepository;
// (...略...)
public UserData get(string userId)
{
var targetId = new UserId(userId);
var user = userRepository.Find(targetId);
// var userData = new UserData(user.Id.Value, user.Name.Value);
// コンストラクタの引数が増える
var userData = new UserData(user.Id.Value, user.Name.Value, user.MailAddress.Value);
return userData;
}
}
この修正は至極単純なもので機械的にこなせますが、UserData オブジェクトをインスタンス化している箇所すべてにおいて同様の修正が必要です。静的言語であればコンパイルエラーで該当箇所は示唆されますし、正規表現や文字列置換を駆使して修正を終えることは可能ですが、あまり面白い作業でもないでしょう。可能であれば修正箇所をまとめたいところです。
修正箇所をまとめるために取れる戦術として、DTO のコンストラクタで User のインスタンスを引数として受け取る方法が考えられます。
public class UserData
{
public UserData(User source)
{
Id = source.Id.Value;
Name = source.Name.Value;
}
public string Id { get; }
public string Name { get; }
}
UserData はコンストラクタの引数として受け取る User と密な関係にあります。User のデータを公開するためのオブジェクトである UserData が User に依存することはあまり問題にはなりません。
もしもパラメータが追加されることになったとしても修正箇所は UserData クラスを変更するだけで十分です。
public class UserApplicationService
{
private readonly IUserRepository userRepository;
// (...略...)
public UserData get(string userId)
{
var targetId = new UserId(userId);
var user = userRepository.Find(targetId);
if (user == null)
{
return null;
}
return new UserData(user);
}
}
ドメインオブジェクトを公開するかしないかは大きな分岐点です。ドメインオブジェクトを公開したからといって即問題が起きるわけではありません。ドメインオブジェクトを非公開にしたことで増えるコード量に煩わしさを感じることもあります。どちらを採用するかはプロジェクトのポリシーによるところです。
アプリケーションサービスと凝集度 - P.144 #
プログラムには凝集度という考えがあります。凝集度はモジュールの責任範囲がどれだけ集中しているかを測る尺度です。凝集度を高めると、モジュールがひとつの事柄に集中することになり、堅牢性・信頼性・再利用性・可読性の観点から好ましいとされます。
この凝集度を測る方法に LCOM(Lack of Cohesion in Methods)という計算式があります。端的に説明するとすべてのインスタンス変数はすべてのメソッドで使われるべき、というもので、計算式はインスタンス変数とそれが利用されているメソッドの式から計算されます。
// 凝集度が低いクラス
public class LowCohesion
{
private int value1;
private int value2;
private int value3;
private int value4;
public int MethodA()
{
return value1 + value2;
}
public int MethodB()
{
return value3 + value4;
}
}
LowCohesion クラスの value1 は MethodA で利用されていますが、MethodB では利用されていません。value1 と MethodB は本質的に関係がありません。同じことが他の属性とふるまいにもいえます。これらを分離することで凝集度はもっと高めることができます。
// 分離したことで凝集度が高いクラス
public class HighCohesionA
{
private int value1;
private int value2;
public int MethodA()
{
return value1 + value2;
}
}
public class HighCohesionB
{
private int value3;
private int value4;
public int MethodB()
{
return value3 + value4;
}
}
いずれのクラスもすべてのフィールドがそのクラスに定義されているすべてのメソッドで利用されています。これは凝集度が高い状態です。
もちろん凝集度を高くすることが常に正解ではありません。そのコードを取り巻く環境によって、あえて凝集度を下げる選択肢が正解となることもありえます。しかしクラスの設計をする上で、凝集度は一考の価値がある尺度であることには間違いないでしょう。
サービスは状態をもたない - P.156 #
アプリケーションサービスは自身のふるまいを変化させる目的で状態を保持しません。サービスが状態をもつようになると、サービスがいまどのような状態にあるのかを気にする必要が出てきてしまいます。しかし、それと同時に勘違いしていけないのは、状態を一切持っていないことを意味しないことです。
たとえば UserApplicationService は状態を持っているサービスです。
public class UserApplicationService
{
private readonly IUserRepository userRepository;
// (...略...)
}
UserApplicationService は IUserRepository 型のフィールド userRepository を状態としてもちますが、userRepository は直接的にサービスのふるまいを変更しません。したがって自身のふるまいを変化させる目的の状態ではありません。
反対に次の状態はふるまいを変化させる目的で保持している状態です。
public class UserApplicationService
{
private bool sendMail;
// (...略...)
public void Register()
{
// (...略...)
if (sendMail)
{
MailUtility.Send("user registered");
}
}
}
Register メソッドは sendMail の値によって処理が分岐します。sendMail は直接的にサービスのふるまいを変更しています。Register メソッドを利用するときにはインスタンスがどういった状態にあるかを気にする必要が出てきてしまいます。
状態がもたらす複雑さは多くの開発者を混乱させるものです。状態をもたせる以外の方法を考えてください。
Chapter 9:複雑な生成処理を行う「ファクトリ」 #
ファクトリの目的 - P.206 #
複雑なオブジェクトはその生成過程も複雑な処理になることがあります。そうした処理はモデルを表現するドメインオブジェクトの趣旨をぼやけさせます。かといって、その生成をクライアントに押し付けるのはよい方策ではありません。生成処理自体がドメインにおいて意味をもたなかったとしても、ドメインを表現する層の責務であることには変わりないのです。
求められることは複雑なオブジェクトの生成処理をオブジェクトとして定義することです。この生成を責務とするオブジェクトのことを、道具を作る工場になぞらえて「ファクトリ」といいます。ファクトリはオブジェクトの生成に関わる知識がまとめられたオブジェクトです。
自動採番機能の活用 - P.214 #
採番処理といえばデータベースの機能として存在する自動採番機能を無視することはできません。
たとえば SQL Server では IDENTITY をカラムに設定するとレコードが挿入された際に自動で採番が行われます。
自動採番処理はデータベースに対しての永続化を行うことで ID が割り振られます。必然的にインスタンスが初めて作られたときには ID が存在していないオブジェクトとして生成されます。また ID を永続化の際に設定するため、セッターを用意する必要ができます。これらはオブジェクトを不安定にさせる要素です。
エンティティは識別子により識別されるオブジェクトです。その識別子が永続化を行うまで存在しないというのは不自然で強烈な制限事項です。誤って識別子が設定されないうちに操作してしまったら、意図しない挙動になるでしょう。開発者は永続化されるまで識別子が生成されないことを常に意識し、最新の注意を払う他ありません。
もうひとつ気になることがあります。それはセッターの存在です。ID プロパティのセッターはリポジトリから操作されるということを前提としています。しかしクラスの定義を見ただけでは、それをうかがい知ることが叶いません。事情を知らない開発者が不意に ID を付け替える記述をしてしまう可能性を残します。
自動採番処理を利用することに決めるといくつかの懸念事項が発生します。しかし、その上であえて自動採番機能によって ID を割り振ることを受け入れる選択肢はもちろんあります。自動採番機能を採用する場合には開発上のルールをよく周知することが必要です。
複雑な生成処理をカプセル化しよう - P.220 #
本来であれば初期化はコンストラクタの役目です。しかしコンストラクタは単純である必要があります。コンストラクタが単純でなくなるときはファクトリを定義します。
「コンストラクタ内で他のオブジェクトを生成するかどうか」はファクトリを作る際の動機付けによい指標となります。もしもコンストラクタが他のオブジェクトを生成するようなことがあれば、そのオブジェクトが変更される際にコンストラクタも変更しなくてはならなくなる恐れがあります。他のオブジェクトをただインスタンス化するだけであったとしても、それは複雑さをはらんでいるのです。
もちろんすべてのインスタンスがファクトリにより生成されるべきと主張しているわけではありません。生成処理が複雑でないのであれば素直にコンストラクタを呼び出す方が好ましいです。ここでの主張は「ただ漫然とインスタンス化をするのではなく、ファクトリを導入すべきか検討する習慣を身につけるべきである」というものです。
Chapter 12:ドメインのルールを守る「集約」 #
集約とは - P.268 #
集約には境界とルートが存在します。集約の境界は集約に何が含まれるのかを定義するための境界です。集約のルートは集約に含まれる特定のオブジェクトです。
外部からの集約に対する操作はすべて集約ルートを経由して行われます。集約の境界内に存在するオブジェクトを外部にさらけ出さないことで、集約内の不変条件を維持できるようにしているのです。
集約の外部から境界の内部のオブジェクトを操作してはいけません。集約を操作するための直接のインタフェースとなるオブジェクトは集約ルート(AR:Agreegate Root)と呼ばれるオブジェクトに限定されます。集約内部のオブジェクトに対する変更は、集約ルートが責任をもって行うことで集約内部の不変条件を保ちます。
オブジェクト指向プログラミングではこのように、外部から内部のオブジェクトに対して直接操作するのではなく、それを保持するオブジェクトに依頼する形を取ります。そうすることで直感的に、かつ不変条件を維持することができるのです。このことは「デメテルの法則」としても知られています。
オブジェクトの操作に関する基本的な原則 - P.273 #
オブジェクト同士が無秩序にメソッドを呼び出し合うと、不変条件を維持することは難しくなります。「デメテルの法則」はオブジェクト同士のメソッド呼び出しに秩序をもたらすガイドラインです。
デメテルの法則によると、メソッドを呼び出すオブジェクトは次の4つに限定されます。
- オブジェクト自身
- インスタンス変数
- 引数として渡されたオブジェクト
- 直接インスタンス化したオブジェクト
たとえば車を運転するときにタイヤに対して直接命令しないのと同じように、オブジェクトのフィールドに直接命令をするのではなく、それを保持するオブジェクトに対して命令を行い、フィールドは保持しているオブジェクト自身が管理すべきだということです。
if (circle.Members.Count >= 29)
{
throw new CircleFullException(id);
}
このコードはサークルに所属するメンバーの数が最大数を超えないように確認をしていますが、Circle オブジェクトのプロパティである Members を直接操作し、Count メソッド(プロパティ)を呼び出しています。これはデメテルの法則が提示している「メソッドを呼び出してよいオブジェクト」のいずれにもあてはまりません。まさにデメテルの法則に違反している例です。
このコードの問題はメンバーの最大数に関わるロジックが点在することを助長することです。
デメテルの法則にしたがうとコードは次のように変化します。
if (circle.IsFull())
{
throw new CircleFullException(id);
}
メンバー数が上限に達しているかは IsFull メソッドを通じて確認されます。上限チェックのコードはすべてこれに置き換わります。
サークルに関わる上限メンバー数の知識はすべて IsFull メソッドに集約されています。もし上限数が変更されるようであれば IsFull メソッドの修正だけで完結します。
ゲッターを避ける理由はまさにここにあります。フィールドがゲッターを通じて公開されていると、本来オブジェクトに記述されるべきルールがいつ何時にどこかで漏れ出すことを防げないのです。
デメテルの法則はソフトウェアのメンテナンス性を向上させ、コードをより柔軟なものへ導きます。
内部データを隠蔽するために - P.276 #
オブジェクトの内部データは無暗やたらに公開すべきではありません。しかし、完全に非公開にしてしまうとリポジトリがインスタンスを永続化しようとしたときに困ったことが発生します。
public class EFUserRepository : IUserRepository
{
public void Save(User user)
{
// ゲッターを利用しデータの詰め替えをしている
var userDataModel = new UserDataModel{
Id = user.Id.Value,
Name = user,Name.Value
};
context.Users.Add(userDataModel);
context.SaveChanges();
}
}
UserDataModel を生成する際には User クラスの Id や Name を利用しているので、もしも User クラスの Id や Name が非公開になってしまうと、このコードはコンパイルエラーになってしまいます。
これに対するアプローチは通知オブジェクトを使う方法です。通知オブジェクトを利用する場合はまず専用のインタフェースを用意します。
public interface IUserNotification
{
void Id(UserId id);
void Name(UserName name);
}
次にこのインタフェースを実装した通知オブジェクトを実装します。
public class UserDataModelBuilder : IUserNotification
{
// 通知されたデータはインスタンス変数で保持される
private UserId id;
private UserName name;
public void Id(UserId id)
{
this.id = id;
}
public void Name(UserName name)
{
this.name = name;
}
// 通知されたデータからデータモデルを生成するメソッド
public UserDataModel Build()
{
return new UserDataModel
{
Id = id.Value,
Name = name.Value
}
}
}
User クラスは通知オブジェクトのインタフェースを受け取り、内部の情報を通知するようにします。
public class User
{
// インスタンス変数はいずれも非公開
private readonly UserId id;
private UserName name;
// (...略...)
public void Notify(IUserNotification note)
{
// 内部データを通知
note.Id(id);
note.Name(name);
}
}
このようにすることでオブジェクトの内部データを非公開にしたまま、外部に対して引き渡せます。
public class EFUserRepository : IUserRepository
{
public void Save(User user)
{
// 通知オブジェクトを引き渡して内部データを取得
var userDataModelBuilder = new UserDataModelBuilder();
user.Notify(userDataModelBuilder);
// 通知された外部データからデータモデルを生成
var userDataModel = useDataModelBuilder.Build();
// データモデルを O/R Mapper に引き渡す
context.Users.Add(userDataModel);
context.SaveChanges();
}
}
もちろんこの場合はコードの記述量が大幅に増えてしまうことが懸念事項です。その懸念を払しょくするには、通知オブジェクトに関連するコードをひとまとめに生成する開発者用の補助ツールを用意するとよいでしょう。
集約をどう区切るか - P.280 #
集約をどのように区切るか、というのはとても難しいテーマです。その方針としてもっともメジャーなものは「変更の単位」でしょう。
集約に対する変更はあくまでもその集約自身に実施させ、永続化の依頼も集約ごとに行われる必要があります。ここまでリポジトリはどの単位で作るのかということに言及していませんでしたが、こういった理由からリポジトリは変更の単位である集約ごとに用意します。
COLUMN:ID のゲッターに対する是非 - P.288 #
ここまでゲッターについては可能な限り排除すべきものとして説明してきたつもりです。しかし、その対象が識別子であった場合は少し事情が変わってきます。
理想論でいえばこのゲッターも排除すべきですが、識別子はエンティティを表現するためのシステマチックな属性で、それ自体が集約の代わりとして扱える便利なものです。一意な識別子自体に関心が寄せられることはありますが(宅急便の追跡番号など)、識別子に対してビジネスルールが記述されることは多くありません。そのようなときは識別子を公開することで発生するデメリットよりも公開することのメリットの方が大きいこともあるでしょう。
集約の大きさと操作の単位 - P.289 #
トランザクションはデータをロックします。集約が大きくなればなるほどロックの範囲もそれに比例して大きくなります。
集約を不用意に大きくしてしまうと、それだけ処理が失敗する可能性を高めます。
集約の大きさはなるべく小さく保つべきです。もしも巨大な集約ができあがってしまったのであれば、それは今一度集約の境界線を見つめ直すチャンスです。
また複数の集約を同一トランザクションで操作することも可能な限り避けます。複数の集約にまたがるトランザクションは、巨大な集約と同様に広範囲なデータロックを引き起こす可能性を高めます。
COLUMN:結果整合性 - P.290 #
それでもなお、複数の集約にまたがるような処理を取り扱いたいときもあります。そういったときに利用できるのが結果整合性です。
トランザクション整合性は即時的な整合性ですが、結果整合性はあるタイミングにおいて矛盾が発生することを許容します。もちろんそのままではシステムが破綻してしまいますので、最終的には整合性を保つような仕組みにより解決をします。
たとえば、これは極端な例ですが、一日1回 cron(ジョブを自動実行するデーモンプロセス)にてユーザをすべて検査し、もしも同じユーザ名のユーザがいたらそのユーザの名前をランダムで重複しない文字列に変更してしまうといったような仕組みです。ひどく乱暴な処理ですが、システム全体としての整合性は保たれます。
システムに必要な整合性を選り分けてみると、即時的な生合成が求められるものは思ったよりも少ないものです。もしも、トランザクションによって問題が発生した際には、結果整合性について一考してみても良いでしょう。
Chapter 13:複雑な条件を表現する「仕様」 #
仕様とは - P.294 #
オブジェクトの評価は単純なものであればメソッドとして定義されますが、すべての評価が単純な処理であるとは限りません。評価処理にはオブジェクトのメソッドとして定義されるには似つかわしくないものも存在します。
そういった複雑な評価の手順は、アプリケーションサービスに記述されてしまうことが多いです。しかしながら、オブジェクトの評価はドメインの重要なルールです。サービスに記述されてしまうことは問題です。
この対策として挙げられるのが仕様です。仕様はあるオブジェクトがある評価基準に達しているかを判定するオブジェクトです。
複雑な評価処理を確認する - P.294 #
あるオブジェクトがある特定の条件にしたがっているかを評価する処理は、オブジェクトのメソッドとして定義されます。これまで取り扱ってきたサークルを表すオブジェクトにも、まさに評価を行うメソッドがありました。
public class Circle
{
// (...略...)
public bool IsFull()
{
return CountMembers() >= 30;
}
}
これほど単純な条件であれば問題ありません。しかし、これよりも、もう少し複雑であった場合はどうでしょうか。例えば確認のためにリポジトリに問い合わせる必要がある場合です。
しかしエンティティや値オブジェクトがリポジトリを操作するのはあまりよくない解決策です。リポジトリはドメイン設計を完成させるといった意味ではドメインのオブジェクトですが、ドメイン由来のものではありません。
エンティティや値オブジェクトがドメインモデルの表現に専念するためには、リポジトリを操作することを可能な限り避ける必要があります。
エンティティや値オブジェクトにリポジトリを操作させないために取られる手段は仕様と呼ばれるオブジェクトを利用した解決です。サークルが満員かどうかを評価する処理を仕様として切り出してみましょう。
public class CircleFullSpecification
{
private readonly IUserRepository userRepository;
public CircleFullSpecification(IUserRepository userRepository)
{
this.userRepository = userRepository;
}
public bool IsSatisfiedBy(Circle circle)
{
var users = userRepository.Find(circle.Members);
var premiumUserNumber = users.Count(user => user.IsPremium);
var circleUpperLimit = premiumUserNumber < 10 ? 30 : 50;
return circle.CountMembers() >= circleUpperLimit;
}
}
仕様はオブジェクトの評価のみを行います。複雑な評価手順をオブジェクトに埋もれさせず切り出すことで、その趣旨は明確になります。
仕様を利用したときのサークルメンバー追加処理は以下です。
public class CircleApplicationService
{
private readonly ICircleRepository = circleReposotiry;
private readonly IUserRepository = userRepository;
// (...略...)
public void Join(CircleJoinCommand command)
{
var circleId = new CircleId(command.CircleId);
var circle = circleRepository.Find(circleId);
var circleFullSpecification = new CircleFullSpecification(userRepository);
if (circleFullSpecification.IsSatisfiedBy(circle))
{
throw new CircleFullException(circleId);
}
// (...略...)
}
}
複雑な評価手順はカプセル化され、コードの意図は明確になっています。
オブジェクトの評価処理をオブジェクト自身に実装すると、オブジェクトの趣旨はぼやけます。オブジェクトが何のために存在し、何を為すのかが見えづらくなるのです。
// 評価メソッドにまみれた定義
public class Circle
{
public bool IsFull();
public bool IsPopular();
public bool IsAnniversary(DateTime today);
public bool IsRecruiting();
public bool IsLocked();
public bool IsPrivate();
public void Join(User user);
}
こうした評価の処理を放置しておくと、オブジェクトに対する依存は手の施しようがないほど増加し、変化に対して痛みを伴うようになります。
あるオブジェクトを評価する方法はメソッドに限ったことではありません。仕様のように外部のオブジェクトとして切り出すことで扱いやすくなることもあると知っておきましょう。
付属データ #
本誌に掲載されているサンプルコードは以下からも確認できる。