ドメイン駆動設計入門を読んだので自分用まとめ
書籍内のサンプルコードが公開されているので記事でも引用しています。
2. 値オブジェクト
- システムに最適な値が必ずしもプリミティブな値とは限らない。
- システム固有の値を「値オブジェクト」と呼ぶ
class FullName
{
public FullName(string firstName, string lastName)
{
FirstName = firstName;
LastName = lastName;
}
public string FirstName{get;}
public string LastName{get;}
}
値の性質
不変である
"こんにちわ".ChangeTo("Hello"); //本来存在しないメソッド
Console.WriteLine("こんにちわ"); // Helloが表示される
交換が可能である
「変更」ではなく「交換」する
// NG
var fullName = new FullName("masanobu", "naruse")
fullName.changeName("masanobu", "sato")
// OK
var fullName = new FullName("masanobu", "naruse")
fullName = new FullName("masanobu", "sato")
等価性によって比較される
等価性は値オブジェクトの属性で比較するのではなく、値オブジェクト自身で比較する
var nameA = new FullName("masanobu", "naruse");
var nameB = new FullName("masanobu", "sato");
// NG
var compareResult = nameA.FirstName == nameB.FirstName
&& nameA.LastName == nameB.LastName;
// OK
compareResult = nameA.Equals(nameB);
ふるまい
値オブジェクトには振る舞いをもたせられる。
ふるまいをもたせると、そのオブジェクトができることがわかる = 仕様がわかる
class Money
{
private readonly decimal amount;
private readonly string currency;
︙
public Money Add(Money arg) {
if (arg == null) throw new ArgumentNullException(nameof (arg));
if (currency != arg.currency) throw new ArgumentException("通貨単位が異なります")
return new Money(amount + arg.amount, currency)
}
}
3. エンティティ
- 属性ではなく、「同一性」で区別されるオブジェクトを、エンティティと呼ぶ
- 人間の趣味が変わっても同一人物
エンティティの特徴
可変である
- ユーザーが名前を変えたいときの例で考えると…
- 値オブジェクトは「代入」で値を変更する
- エンティティは「ふるまい」で値を変更する
- ただのセッターではなく、どういう決まりごとがあるかが語られる
同じ属性であっても区別される
- 同姓同名の2人は、属性は同じでも異なる人物
- システムでは同一性を表現するために、「Identity」が用いられる
同一性をもつ
- ユーザ名を変更する前と変更した後で、同一のユーザーと判定される
- 属性ではなく「Identity」でのみ判断される
class User
{
private readonly UserId id; // 識別子
}
エンティティ or 値オブジェクトの判断基準
- ライフサイクルを持つか?
- 作成されてから削除されるまで、変化するものはエンティティ
- それ以外はとりあえず値オブジェクトで作ると良い
4. ドメインサービス
- 値オブジェクトやエンティティに記述すると不自然なふるまいを解決するためのオブジェクト
ユーザーの重複を確認するコードを、ユーザーエンティティに持たせてみると、自分自身が重複しているか確認するという不自然なコードになってしまう
var user = new User(userId, userName);
var res = user.exists(user); // 生成したオブジェクト自身に問い合わせている
ドメインサービスを定義すると、自然にふるまいを記述できる
class UserService
{
public bool Exists(user) {
// 重複チェック
}
}
var userService = new UserService();
var user = new User(userId, userName);
var res = userService.exists(user);
可能な限りドメインサービスを避ける
- 例えば、ユーザー名の変更をドメインサービスに記述してみる
- すると、ユーザーエンティティにはただのゲッターセッターしか残らない
- 「ドメインモデル貧血症」が起きてしまう
- すると、ユーザーエンティティにはただのゲッターセッターしか残らない
- 可能な限りドメインサービスを避け、値オブジェクト・エンティティにふるまいをもたせるべき
5. リポジトリ
ドメインオブジェクトの永続化と再構築を担うパターン。
なんらかのデータストアと直接やりとりする。
class Program
{
private IUserRepository userReopsitory;
public Program(IUserRepository userRepository){
this.userRepository = userRepository;
}
public void CreateUser(string userName){
var user = new User(new UserName(userName));
var userService = new UserService(userRepository);
if (userService.Exists(user)) { // Existsは実際の重複チェックをリポジトリ経由で行う
throw new Exception();
}
userRepository.Save(user); // どこのデータストアに保存されるかに関心を持たない
}
}
- ※ Existsをリポジトリに実装するのはNG?
- NG。リポジトリの責務である「永続化」の範疇を超えているため。
- 重複チェックはドメインルールに近いので、ドメインサービスが行うべき。
リポジトリの実装
- Interfaceを用意し、Find, Saveなどのメソッドを定義する
- ユースケースが特定のインフラに依存しないようにするため、抽象型を使う
- SQLRepository
- InMemoryRepository(テスト用)
- ORMRepository など
- ユースケースが特定のインフラに依存しないようにするため、抽象型を使う
public interface IUserRepository
{
void Save(User user);
User Find(UserName name)
}
- 永続化に関するふるまいを定義(メソッド名は任意)
-
Save(Entity)
- 個別のフィールドのUpdateは実装しない。updateName, updateEmail…と膨らんでいくため
- そういった変更のふるまいはエンティティにもたせる
Delete(Entity)
-
- 再構築に関するふるまいを定義(メソッド名は任意)
Find(Identity)
FindAll()
-
FindByHoge(Fuga)
(特定の探索に特化)- 可能なら、Findをオーバーロードする手段もある
6. アプリケーションサービス
ユースケースを実現する、ドメインオブジェクトを組み合わせ実行するスクリプト
- ユーザー機能
- ユーザーを登録する
- ユーザー情報を変更する
- ユーザーを削除する
public class UserApplicationService
{
private readonly IUserRepository userRepository;
private readonly UserService userService
public UserApplicationService(IUserRepository userRepository, UserService userService){
this.userRepository = userRepository;
this.userService = userService;
}
public void Register(string name) {
var user = new User(new UserName(name));
if (userService.exists(user)) throw new Exception();
userRepository.Save(user);
}
}
DTOを使う
アプリケーションサービスがドメインオブジェクトを返してしまうと、クライアントが直接ドメインオブジェクトのメソッドを使うことができてしまう。
ドメインオブジェクトを公開してしまうと、本来アプリケーションサービス1箇所に記述されるべきロジックが様々な箇所に散ってしまうので、DRYに反する危険性がある。
// ApplicationServiceを呼び出すクライアント
public class Client
{
private UserApplicationService userApplicationService;
public void ChangeName(string id, string name)
{
var target = userApplicationService.Get(id);
var newName = new UserName(name);
target.ChangeName(newName); // クライアントが直接ドメインオブジェクトのメソッドを呼び出している
}
}
DTOに移し替えることで、外部に必要なデータだけを公開できる。
public class UserData
{
public UserData(User source) {
Id = source.Id.Value;
Name = source.Name.Value;
// MailAddress = source.MailAddress.Value こんな感じで簡単に増やせる
}
public string Id { get; }
public string Name { get; }
}
CommandObjectを使う
例えばユーザー情報を変更する場合、変更できる情報が増えればメソッドのシグニチャも増えていく。
// シグニチャがどんどん肥大化していく
public void Update(string userId, string name = null, string mailAddress = null) {
}
これを解決するため、CommandObjectを利用する。
public class UserUpdateCommand
{
public UserUpdateCommand(string id, string name = null, string mailAddress = null){
Id = id;
Name = name;
MailAddress = mailAddress;
}
public string Id {get;}
public string Name { get; }
public string MailAddress { get; }
}
アプリケーションサービスの注意点
- ドメインロジックを書かない
- 例えばユーザー登録・更新の重複チェックはドメインサービスに任せる
- ドメインのルールはドメインオブジェクトに閉じ込めることで、DRYを保てる
-
凝集度を高める
- 全てのメソッドが、全てのフィールドを使っている状態は「凝集度が高く」、コードの見通しが良い。
- 一部のフィールドを使っていないメソッドは、別クラスに切り出せる可能性がある。
- 状態を持たない
- インスタンスの状態によってふるまいを変えない。
7. 依存
あるオブジェクト(A)からあるオブジェクト(B)を参照するだけで、依存が発生する。
以下の例では、UserApplicationServiceが特定の技術基盤に依存している問題がある。この場合はIUserRepository
といった抽象に依存すべきである。
public class UserApplicationService
{
private UserRepository userRepository;
}
// [UserApplication] --> [UserRepository]
依存関係逆転の原則
- 上位レベルのモジュールは下位レベルのモジュールに依存してはならない。どちらのモジュールも抽象に依存すべきである。
UserApplicationService --> IUserRepository
UserRepository --o IUserRepository
- 抽象は、実装の詳細に依存してはならない。実装の詳細が抽象に依存すべきである。
- 高レベルなモジュール(
UserApplicationService
)を主体に、インターフェイス(IUserRepository
)を宣言する。- 実装の詳細は、宣言されたインターフェイスに合わせる
- 高レベルなモジュール(
依存関係のコントロール
Service Locatorパターン
事前にServiceLocator
オブジェクトに依存関係の解決先を登録しておき、必要になったらインスタンスを取得するパターン
ServiceLocator.Register<IUserRepository, InMemoryUserRepository>();
public class UserApplicationService
{
private readonly IUserRepository userRepository;
public UserApplicationService()
{
// ServiceLocator経由でインスタンスを取得する
this.userRepository = ServiceLocator.Resolve<IUserRepository>();
}
}
- Pros
- 導入しやすい
- Cons
- 依存関係が外部から見えづらい
- コンストラクタで依存関係を解決しているため、クライアントは「事前にServiceLocatorの設定が必要」だということがわかりづらい
- テストの維持が難しい
- 依存関係を追加すると、コンストラクタでエラーが発生するためテストが壊れる
- 依存関係が外部から見えづらい
IoC Containerパターン
コンストラクタでDIできるようにし、依存するオブジェクトのインスタンス化をIoC Containerで行う
public class UserApplicationService
{
private readonly IUserRepository userRepository
public UserApplicationService(IUserRepository userRepository) {
this.userRepository = userRepository
}
}
9. ファクトリ
複雑なオブジェクトの生成処理を定義するオブジェクト。
オブジェクトの生成過程もドメイン知識といえる。
例:ユーザーIDの自動採番
- UserクラスのコンストラクタでDBの採番テーブルからIDを取得
- これはNG。特定の技術要素に偏っている
- UserFactoryで自動採番したUserクラスのインスタンスを返す
- これはOK。
- インターフェイスを定義すれば、採番処理を別の方法にも置き換えられる。
public interface IUserFactory
{
User Create(UserName name);
}
ファクトリメソッド
なんらかのオブジェクトのインスタンス化で必要な、自身のフィールドをゲッターで公開したくない場合に代わりにインスタンスを生成して返せる。
public class User
{
private readonly UserId id;
public Circle CreateCircle(circleName circleName){
return new Circle(
id,
circleName
);
}
}
10. 整合性
システムに矛盾がない状態を作る
例:ユーザ登録処理内の重複確認Exists
メソッドは、複数のUserが並列して実行するとチェックをすり抜けてしまう
- ユニークキーによる防衛
- 結論、ルールを守る主体ではなく万が一のセーフティネットとして使うべき
- Pros
- お手軽
- Cons
- コードにドメインのルールが書かれない
- 特定の技術基盤に依存する
- トランザクションによる防衛
- Pros
- コード上にドメインルールを表現できる
- Minos
- 特定の技術基盤に依存する
- C#のトランザクションスコープや、AOPによる
@Transaction
アノテーションを使えば特定の技術基盤から開放される - ユニットオブワークでトランザクションを実現する方法もある
- C#のトランザクションスコープや、AOPによる
- 特定の技術基盤に依存する
- Pros
12. 集約
オブジェクトの「不変条件」を維持する単位
例:User集約にはUser
(集約ルート)、UserId
、UserName
が存在する。
集約の外部から集約ルート以外のオブジェクトは操作できないようにすることで不変条件を守る
// NG Circle内のMemberを直接操作してしまっている
circle.Members.Add(member);
// OK 集約ルートのcircle経由で操作している
circle.Join(user);
デメテルの法則
メソッドを呼び出していいオブジェクトは4種類
- オブジェクト自身
- インスタンス変数
- 引数として渡されたオブジェクト
- 直接インスタンス化したオブジェクト
circle.Members.Add(member)
は、「オブジェクトのインスタンス変数」のメソッドを呼び出しているので、デメテルの法則に反している
13. 仕様
複雑な評価処理を行う仕様オブジェクト。
単純な判定処理はメソッドとしてエンティティ・値オブジェクトに持たせてもよいが、複雑な判定処理はドメインオブジェクトの意図が見えづらくなる。
public class CircleMembersFullSpecification
{
public bool IsSatisfiedBy(CircleMembers members)
{
var premiumUserNumber = members.CountPremiumMembers(false);
var circleUpperLimit = premiumUserNumber < 10 ? 30 : 50;
return membersCountMembers() >= circleUpperLimit;
}
}
リポジトリと組み合わせる
ドメイン知識がリポジトリに依存することを防ぐ
例)おすすめサークルを探し出すメソッド
以下の例は実装すれば動作するが、リポジトリにドメインルールが流出している。
public interface ICircleRepository
{
public List<Circle> FindRecommended(DateTime now);
}
仕様オブジェクトにおすすめサークルの条件を表現すると、リポジトリへのドメインルール流出を防ぐことができる。
public class CircleRecommendSpecification
{
private readonly DateTime executeDateTime;
public CircleRecommendSpecification(DateTime executeDateTime) {
this.executeDateTime = executeDateTime;
}
public bool IsSatisfiedBy(Circle circle) {
if (circle.CountMembers() < 10) {
return false;
}
return circle.Created > executeDateTime.AddMonths(-1);
}
}
︙
var recommendCircleSpec = new CircleRecommendSpecification(now);
var circles = circleRepository.FindAll();
var recommendCircles = circles
.Where(recommendCircleSpec.IsSatisfiedBy)
.Take(10)
.ToList();
return new CircleGetRecommendResult(recommendCircles);