こちらの記事は大幅にボリュームアップ(8万文字→30万文字)して書籍化されました!
Link: ドメイン駆動設計入門 ボトムアップでわかる! ドメイン駆動設計の基本
はじめに
この記事は前後編に分かれています。
順序だてた解説になっているので最後までお付き合いいただけると幸いです。
後編記事: https://nrslib.com/bottomup-ddd-2/
順序立っての説明になっておりますので、前編からご覧になることを強くお勧めします。
セミナー情報
こちらの内容のセミナーを不定期で開催しています。
◆セミナーページ
第一回: https://ddd-community-jp.connpass.com/event/103428/
第二回: https://ddd-community-jp.connpass.com/event/107106/
第三回: https://nrs-seminar.connpass.com/event/117283/
◆あとがき
第一回ボトムアップドメイン駆動設計勉強会を開催しました
セミナースライド
まえがき
この章は飛ばしても構いません。
この記事を書くにあたって
ドメイン駆動設計について学習しているときに常々感じていたことがあります。
それは「壮大すぎる」ということです。
ドメイン駆動設計は大方の説明ではまずユビキタス言語やコンテキストマップの説明から入ります。
これらの概念はドメイン駆動設計の根底に関わるものですので非常に重要です。この概念の理解なくしてはドメイン駆動設計を習得したとは言い難いです。
しかし、初学者としてドメイン駆動設計の門戸を叩いた時、まずはユビキタス言語の説明をされ「ドメインエキスパート(業務に精通した人)と一緒にモデリングを行っていきましょう」と言われたらどのように感じるでしょうか。
初学者が皆都合よく、ドメインエキスパートが協力してくれる環境にいるのでしょうか。
多くはどうにも自分には実現不可能なようだ、と感じて踵を返してしまうのではないでしょうか。
そうして初学者が踵を返してしまう状況が続くようであれば、ドメイン駆動設計はいつまで経っても実践されない空論になってしまいます。
これは非常に勿体ないことだと思います。
ドメイン駆動設計は壮大ではありますが、その実オブジェクト指向を最大限に活用しただけの設計です。
ドメイン駆動設計が合う合わないは存在します。
合わないとする理由は所属している組織の環境であったり、個人の趣味嗜好であったりと様々な理由があるでしょう。
しかし、どのような知識であってもその可否を判断できるのは、ある程度全体を俯瞰できるようになったときこそ初めて合う合わないの判断が下せるようになるものだと思います。
この記事は理解や実践が難しいものについては後回しにし、理解しやすく実践しやすい小さい部分のパターンからボトムアップ的にシステムを構築して「動くものが作れるんだ」という実感をつかんでほしいという思いから作られました。
構成について
この記事はとにかくコードベースでドメイン駆動設計で利用されるパターンを利用してみて、ドメイン駆動設計の雰囲気を感じ取るためのものです。
そのため解説はなるべくソースコードをベースに説明します。
言語については C# をサンプルに利用しています。
極力 C# 特有の構文を使わないようにしているので、ほかのオブジェクト指向言語に置き換えることは容易かと思います。
始めは小さなモデリングのパターンから始まり、最終的には MVC フレームワークを用いた Web システムを構築します。
Web システムはサンプルコードも用意してあります。もしよければご参考ください。
モデリング
値オブジェクト
小さく始めるということで最も理解がしやすいものからはじめようと考えました。
最も理解がしやすいものは何か。一番に思い浮かんだのが、この値オブジェクトです。
まずはこの値オブジェクトを足がかりとしてドメイン駆動設計の門戸を開いてみましょう。
値オブジェクトとは
値といわれてすぐに思い浮かぶものは何でしょうか。
おそらく真っ先に「文字列」や「数字」などが思い浮かぶのではないでしょうか。
私たちはこれらの値を利用して、プログラミングを行い処理を実現しています。
例えば、文字列を使って氏名を表現することがあるでしょう。
string fullname; |
実際に値を代入してみましょう。
string fullname = "tanakataro"; |
では少し趣向を変えて氏名から名字だけを取得してみましょう。
そうすると困った事態になってしまうのではないでしょうか。
“tanakataro” のうち名字である部分を取得するロジックが思いつきません。
名字を取得できるようにするにはデータに細工をする必要があるようです。
例えば名字と名前を空白で区切ったらどうでしょうか。
string fullname = "tanaka taro"; | |
string[] tokens = fullname.Split(' '); | |
string familyname = tokens[0]; // 無事名字が取得できた |
ところで次のような名前はどうでしょうか。
string fullname = "john smith"; |
先ほどの名字の取得方法を実践してみると……。
string fullname = "john smith"; | |
string[] tokens = fullname.Split(' '); | |
string familyname = tokens[0]; // john |
これは名字ではありません。
どうにもこのやり方ではデータによってロジックを切り替える必要が出てきてしまうようです。
こういった問題を解決するときに通常利用されるのがクラスです。
public class FullName { | |
private readonly string firstname; | |
private readonly string familyname; | |
public FullName(string firstname, string familyname){ | |
this.firstname = firstname; | |
this.familyname = familyname; | |
} | |
public string FirstName { get { return firstname; } } | |
public string FamilyName { get { return familyname; } } | |
} |
var fullname1 = new FullName("taro", "tanaka"); | |
Console.WriteLine(fullname1.FamilyName); // "tanaka" | |
var fullname2 = new FullName("john", "smith"); | |
Console.WriteLine(fullname2.FamilyName); // "smith" |
ドメイン駆動設計ではこういったクラスのことを値オブジェクトとしています。
値オブジェクトのルール
値オブジェクトにはいくつかのルールが存在します。
- 状態を不変に保つ
- 同じ値オブジェクト同士で値が等しいかどうかの確認ができる
- 完全に交換可能である
それぞれ詳しく見てみましょう。
状態を不変に保つ
クラスはミュータブル(mutable, 可変)なクラスとイミュータブル(Immutable, 不変)なクラスに分けることができます。
FullName クラスはミュータブルでしょうか。イミュータブルでしょうか。
public class FullName { | |
private readonly string firstname; | |
private readonly string familyname; | |
public FullName(string firstname, string familyname){ | |
this.firstname = firstname; | |
this.familyname = familyname; | |
} | |
public string FirstName { get { return firstname; } } | |
public string FamilyName { get { return familyname; } } | |
} |
この FullName クラスはイミュータブルなクラスです。
では FullName クラスをミュータブルなクラスにしてみます。
public class FullName { | |
private string firstname; | |
private string familyname; | |
public FullName(string firstname, string familyname){ | |
this.firstname = firstname; | |
this.familyname = familyname; | |
} | |
public string FirstName { get { return firstname; } } | |
public string FamilyName { get { return familyname; } } | |
public void ChangeFamilyName(string familyname){ | |
this.familyname = familyname; | |
} | |
public void ChangeFirstName(string firstname){ | |
this.firstname = firstname; | |
} | |
} |
状態を変更出来るこのクラスはミュータブルです。
このようにクラスは「イミュータブルなクラス」と「ミュータブルなクラス」の二種類に分別できます。
さて、このイミュータブルなクラスとミュータブルなクラスでは、どちらが値オブジェクトの実装として相応しいのでしょうか。
「状態を不変に保てる」というルールに照らし合わせると、値オブジェクトに相応しい実装はイミュータブルなクラスです。
値オブジェクトはそれ自身が値として振舞います。
値は一貫して変化することがありません。
もし 0 という数値がプログラムの実行途中で 1 という数値に変更されてしまったら混乱を引き起こしてしまいます。
// 値が変更できる処理は以下のようなことを実現する | |
var i = 0; | |
0.ChangeTo(1); // こんなメソッドはないです | |
Console.WriteLine(i); // 1 |
値としての役割を持たされる値オブジェクトもまた、不変であるべきです。
また、不変にすることで
- フィールドが変更されることを考慮せずに済む
- 並列や並行で実行した際に状態が変更される恐れがない
- キャッシュしてメモリを節約することが可能になる
という明確なメリットが存在します。
これらは値オブジェクトに限らず適用されるメリットですので、通常のクラスであっても可能であればイミュータブルになるように意識するとよいでしょう。
同じ値オブジェクト同士で値が等しいかどうかの確認ができる
値は値同士で比較が出来ます。
Console.WriteLine(0 == 0); // true | |
Console.WriteLine(0 == 1); // false | |
Console,WriteLine('a' == 'a'); // true | |
Console,WriteLine('a' == 'b'); // false | |
Console.WriteLine("hello" == "hello"); // true | |
Console.WriteLine("hello" == "こんにちは"); // false |
値オブジェクトも同じように、値オブジェクト同士の比較手段を提供する必要があります。
それでは FullName クラスも値オブジェクトにするべく、比較の手段を提供してみましょう。
public class FullName : IEquatable<FullName> { | |
private readonly string firstname; | |
private readonly string familyname; | |
public FullName(string firstname, string familyname) { | |
this.firstname = firstname; | |
this.familyname = familyname; | |
} | |
public string FirstName { get { return firstname; } } | |
public string FamilyName { get { return familyname; } } | |
public bool Equals(FullName other){ | |
if (ReferenceEquals(null, other)) return false; | |
if (ReferenceEquals(this, other)) return true; | |
return string.Equals(firstname, other.firstname) && string.Equals(familyname, other.familyname); | |
} | |
public override bool Equals(object obj){ | |
if (ReferenceEquals(null, obj)) return false; | |
if (ReferenceEquals(this, obj)) return true; | |
if (obj.GetType() != this.GetType()) return false; | |
return Equals((FullName) obj); | |
} | |
public override int GetHashCode(){ | |
unchecked{ | |
return ((firstname != null ? firstname.GetHashCode() : 0) * 397) ^ (familyname != null ? familyname.GetHashCode() : 0); | |
} | |
} | |
} |
※ C# の Equals を提供するときのルールのため GetHashCode などのメソッドも用意しています。
このメソッドを利用することで値オブジェクト同士が等しいかどうかの確認が可能になります。
var name1 = new FullName("taro", "tanaka"); | |
var name2 = new FullName("john", "smith"); | |
Console.WriteLine(name1.Equals(name2)); |
完全に交換可能である
プログラムにおいて値と言われたら思い浮かぶのは数字や文字、文字列などのプリミティブな値が思い浮かぶと思います。
このプリミティブな値を変更するプログラムを記述してみましょう。
int num = 0; | |
num = 1; | |
char c = 'a'; | |
c = 'b'; | |
string greet = "こんにちは"; | |
greet = "hello"; |
これは値を変更したのでしょうか。
いいえ、 ‘0’ という値を ‘1’ という値に変更することは不可能です。
0.Change(1); |
代入とはつまり値を「変更」しているのではなく、値を「交換」しているのです。
値オブジェクトもまた交換が可能です。
交換することにより変更を表現します。
var name = new FullName("taro", "tanaka"); | |
name = new FullName("john", "smith"); |
値オブジェクトを作るモチベーション
値オブジェクトを作る動機としては、値に振る舞いを持たせたいというのが動機になります。
それ以外の動機として「存在しない値を存在させない」ということと「間違った代入を防ぐ」というのも動機になることがあります。
存在しない値を存在させない
例えばユーザ名を表す値オブジェクトを考えてみましょう。
ユーザ名はユーザが任意につけることができますが、ユーザ名の長さは無限というわけにはいきませんね。
そこでユーザ名は 50 文字以下とします。
このユーザ名を表す値オブジェクトは次のようになるでしょう。
public class UserName : IEquatable<UserName> { | |
private readonly string name; | |
public UserName(string name) { | |
if (string.IsNullOrEmpty(name)) { | |
throw new ArgumentNullException(nameof(name)); | |
} | |
if (name.Length > 50) { | |
throw new ArgumentOutOfRangeException("It must be 50 characters or less", nameof(name)); | |
} | |
this.name = name; | |
} | |
public string Value { get { return name; } } | |
public bool Equals(UserName other) { | |
if (ReferenceEquals(null, other)) return false; | |
if (ReferenceEquals(this, other)) return true; | |
return string.Equals(name, other.name); | |
} | |
public override bool Equals(object obj) { | |
if (ReferenceEquals(null, obj)) return false; | |
if (ReferenceEquals(this, obj)) return true; | |
if (obj.GetType() != this.GetType()) return false; | |
return Equals((UserName) obj); | |
} | |
public override int GetHashCode() { | |
return (name != null ? name.GetHashCode() : 0); | |
} | |
} |
このように「本来存在してはいけない値」というものを存在させないようにすることができます。
誤った代入を防ぐ
次のようなクラスがあります。
public class User{ | |
public string Id { get; set; } | |
public string UserName { get; set; } | |
} |
public User CreateUser(string name){ | |
return new User{ | |
Id = name | |
}; | |
} |
正しいか間違っているかは正直わかりません。
名前を Id に使うパターンは存在するでしょうし、ユーザ名はまた別に設定できるかもしれません。
何よりコンパイルが通るので動作します。
それではこちらのクラスの場合はどうでしょうか。
public class User{ | |
public UserId Id { get; set; } | |
public UserName UserName { get; set; } | |
} |
この User オブジェクトを戻すメソッドを作ってみます(比較しやすいように引数も値オブジェクトを受け取ります)。
public User CreateUser(UserName name){ | |
return new User{ | |
UserName = name | |
}; | |
} |
値オブジェクトのおかげで、間違ってユーザ名が Id に代入されることがなくなったのです。
エンティティ
値オブジェクトと並ぶモデリング要素がエンティティです。
ドメイン駆動設計ではおよそデータというものは、このエンティティと値オブジェクトに分けられます。
値オブジェクトとの違い
ドメイン駆動設計におけるモデリングの最小要素は値オブジェクトとエンティティの二つです。
値オブジェクトでないものは全てエンティティであるといえます。
値オブジェクトとの違いを比べることはエンティティの理解を助けることになるでしょう。
エンティティは値オブジェクトの特徴とは対照的に次のような特徴を持っています。
- 可変
- 同じ属性でも区別される
- 同一性を持つ
一つずつ解説をしていきます。
可変
値オブジェクトは不変なオブジェクトでしたが、エンティティは可変なオブジェクトです。
例えば氏名のデータを持った User クラスを考えてみましょう。
class User{ | |
private FullName fullname; | |
public User(FullName fullname){ | |
this.fullname = fullname; | |
} | |
} |
ユーザは登録した氏名を変更することができます。
そのための振る舞いを追加してみましょう。
class User{ | |
private FullName fullname; | |
public User(FullName fullname){ | |
this.fullname = fullname; | |
} | |
public void ChangeName(FullName newName){ | |
if(newName == null) throw new NullArgumentException(nameof(newName)); | |
fullName = newName; | |
} | |
} |
このようにエンティティは振る舞いにより属性を変更することができるのです。
同じ属性でも区別される
値オブジェクトは属性(フィールド)が同じであれば同じ物として扱うことができました。これは値オブジェクト自体が属性の表現であるからです。
それに比べてエンティティは属性が全て同一であっても同じ物として扱うことができません。
具体的な例で説明をすると “tanakataro” という「文字列」と “tanakataro” という「文字列」は全く同じ物ですが “tanakataro” という「ユーザ」と “tanakataro” という「ユーザ」は必ずしも同じ人物ではありません。
同姓同名のユーザが存在することは往々にしてあります。
属性が全く同じオブジェクトを区別できるようにエンティティには識別子が必ず必要です。
// 識別子となるオブジェクトの定義 | |
public class UserId : IEquatable<UserId> { | |
public UserId(string id) { | |
this.Value = id; | |
} | |
public string Value { get; } | |
public bool Equals(UserId other) { | |
if (ReferenceEquals(null, other)) return false; | |
if (ReferenceEquals(this, other)) return true; | |
return string.Equals(Value, other.Value); | |
} | |
public override bool Equals(object obj) { | |
if (ReferenceEquals(null, obj)) return false; | |
if (ReferenceEquals(this, obj)) return true; | |
if (obj.GetType() != this.GetType()) return false; | |
return Equals((UserId) obj); | |
} | |
public override int GetHashCode() { | |
return (Value != null ? Value.GetHashCode() : 0); | |
} | |
} | |
// User オブジェクトは識別子を持つ | |
public class User { | |
private readonly UserId id; | |
private FullName name; | |
// データベース等から読み取ったときに利用するコンストラクタ | |
public User(UserId id, FullName name) { | |
this.id = id; | |
this.name = fullname; | |
} | |
// 生成するときのコンストラクタ(guid が設定される) | |
public User(FullName name){ | |
this.id = new UserId(Guid.NewGuid().ToString()); | |
this.name = name; | |
} | |
public void ChangeName(FullName newName) { | |
if (newName == null) throw new ArgumentNullException(nameof(newName)); | |
name = newName; | |
} | |
} |
もちろん Id オブジェクト以外にも、ユーザ登録にはメールアドレスが必要等のルールに基づいてメールアドレスが識別子となったり、他の値オブジェクトやプリミティブな値が識別子になることもあります。
同一性を持つ
エンティティには必ずライフサイクルが存在します。
そのライフサイクルの中で属性が変わったとしてもそれが同一であることがわかるようにする必要があります。
わかりやすい例として、ユーザを登録してから名前を変更してみます。
var name = new FullName("taro", "tanaka"); | |
var user = new User(name); // 登録したユーザ | |
var newName = new FullName("taro", "sato"); | |
user.ChangeName(newName); // 名前を変更した後のユーザ |
多くの場合は同一と認識してほしいでしょう。
登録している名前を変更したら全く別のユーザーになってしまうようなシステムでは大変です。
よって、エンティティは属性が異なっても同一と見なす必要があります。
同一のエンティティかどうかを判定する際には識別子を用いて比較します。
public class User : IEquatable<User> { | |
private readonly UserId id; | |
private FullName name; | |
public User(UserId id, FullName name) { | |
this.id = id; | |
this.name = name; | |
} | |
public User(FullName name) { | |
id = new UserId(Guid.NewGuid().ToString()); | |
this.name = name; | |
} | |
public void ChangeName(FullName newName) { | |
if (newName == null) throw new ArgumentNullException(nameof(newName)); | |
this.name = newName; | |
} | |
public bool Equals(User other) { | |
if (ReferenceEquals(null, other)) return false; | |
if (ReferenceEquals(this, other)) return true; | |
return Equals(id, other.id); | |
} | |
public override bool Equals(object obj) { | |
if (ReferenceEquals(null, obj)) return false; | |
if (ReferenceEquals(this, obj)) return true; | |
if (obj.GetType() != this.GetType()) return false; | |
return Equals((User) obj); | |
} | |
public override int GetHashCode() { | |
return (id != null ? id.GetHashCode() : 0); | |
} | |
} |
同一性ではなく属性を比較する必要が出来た場合は属性比較メソッドを用意する必要があるでしょう。
ドメインサービス
例えばユーザを登録するときに「ユーザ名の重複を許してはいけない」というルールはよくあるでしょう。
そのルールを記載すべきはどこでしょうか。
ルールはなるべくなら関連するものの近くに記述しておきたいものです。
ユーザに関することはユーザに記載するということで User クラスに記述してみます。
// User の定義情報 | |
public class User { | |
// データベース等から読み取ったときに利用するコンストラクタ | |
public User(UserId userId, UserName username, FullName fullname); | |
// 生成するときのコンストラクタ(guid が設定される) | |
public User(UserName username, FullName fullname); | |
public bool IsDuplicated(User user); | |
} |
すると少々おかしな状況になるのではないでしょうか。
var username = new UserName("ttaro"); | |
var name = new FullName("taro", "tanaka"); | |
var user = new User(username, name); | |
user.IsDuplicated(user); // 自身に重複しているかを問い合わせる? |
単純に処理を読み解くと “ttaro” というユーザに “ttaro” ユーザは重複しているかと問い合わせている形になるので true を返してきそうです。
これでは混乱の元になってしまいそうですね。
ではチェック用のオブジェクトをインスタンス化してみるというのはどうでしょうか。
var username = new UserName("ttaro"); | |
var name = new FullName("taro", "tanaka"); | |
var user = new User(username, name); | |
// チェック用にオブジェクトを作る? | |
var checkUsername = new UserName("forcheck"); | |
var checkName = new FullName("for", "check"); | |
var forCheck = new User(checkUsername, checkname); | |
forCheck.IsDuplicated(user); |
しかし、”forcheck” というユーザは一体何者なのでしょうか。
仮とはいえ存在しないユーザを作るのが正しいプログラムなのでしょうか。
このようにエンティティに関係するロジックであるけれども、エンティティに実装すると違和感を産むロジックは必ず存在します。
そういったロジックを受け持つものとしてドメインサービスが存在します。
class UserService{ | |
public bool IsDuplicated(User user); | |
} |
User エンティティ専用の横断的な知識(User 間での重複チェック等)を実装することを許します。
ドメインサービスに記述するか迷ったとき
ドメインサービスとエンティティのどちらにロジックを記述するか迷ったときはエンティティに記述してください。
ドメインサービスと値オブジェクトのどちらにロジックを記述するか迷ったときは値オブジェクトに記述してください。
ドメインサービスはエンティティや値オブジェクトに関する処理ではありますが、エンティティや値オブジェクトに実装するのが自然でない場合のみに扱います。
結果としてドメインサービスを用意する必要がないことも往々にしてあり得ます。
各モデルを利用する
ここまででモデリングの要素はすべて出そろいました。
あとはこれまでの要素を利用するだけでビジネスロジックを表現することができるのです。
早速プログラミングをしてみましょう。
今回利用するモデルはいままでサンプルにしていた User モデルです。
public class User { | |
private readonly UserId id; | |
private UserName userName; | |
private FullName name; | |
public User(UserId id, UserName userName, FullName name) { | |
this.id = id; | |
this.userName = userName; | |
this.name = name; | |
} | |
public User(UserName userName, FullName name) { | |
id = new UserId(Guid.NewGuid().ToString()); | |
this.userName = userName; | |
this.name = name; | |
} | |
public UserId Id { | |
get { return id; } | |
} | |
public UserName UserName { | |
get { return userName; } | |
} | |
public FullName Name { | |
get { return name; } | |
} | |
public bool EqualsEntity(User arg) { | |
if (ReferenceEquals(null, arg)) return false; | |
if (ReferenceEquals(this, arg)) return true; | |
return id.Equals(arg.id); | |
} | |
public void ChangeUserName(UserName newName) { | |
if(newName == null) throw new ArgumentNullException(nameof(newName)); | |
userName = newName; | |
} | |
public void ChangeName(FullName newName) { | |
if (newName == null) throw new ArgumentNullException(nameof(newName)); | |
this.name = newName; | |
} | |
} |
ユーザ登録
まずは手始めにユーザ登録をするロジックを書いてみます。
登録したいユーザ名を引数で受け取り、重複しないようであれば登録をするメソッドを作ってみましょう。
class Program{ | |
public void CreateUser(string username, string firstname, string familyname){ | |
var user = new User( | |
new UserName(username), | |
new FullName(firstname, familyname) | |
); | |
var userService = new UserService(); | |
if(userService.IsDuplicated(user)){ | |
throw new Exception("重複しています"); | |
}else{ | |
// ユーザを登録……? | |
} | |
} | |
} |
しかしここで、モデルの保存処理という今までに出てきていない要素が出てくることに気づきます。
データを保存する媒体としてデータベースを選択した場合は次のような処理になるのでしょうか。
class Program{ | |
public void CreateUser(string username, string firstname, string familyname){ | |
var user = new User( | |
new UserName(username), | |
new FullName(firstname, familyname) | |
); | |
var userService = new UserService(); | |
if(userService.IsDuplicated(user)){ | |
throw new Exception("重複しています"); | |
}else{ | |
using(var con = new MySqlConnection(Config.ConnectionString)){ | |
con.Open(); | |
using(var command = new MySqlCommand(con)){ | |
command.CommandText = "INSERT INTO t_user VALUES(@firstname, @familyname)"; | |
command.Parameters.Add(new MySqlParameter("@id", user.Id.Value); | |
command.Parameters.Add(new MySqlParameter("@firstname", user.Name.FirstName); | |
command.Parameters.Add(new MySqlParameter("@familyname", user.Name.FamilyName); | |
command.ExecuteNonQuery(); | |
} | |
} | |
} | |
} | |
} |
このコードは柔軟性にも乏しいコードです。
もしデータストアが RDB でなく NoSQL や API になったときはどうすればよいでしょうか。
テストをしたいときはデータベースにデータを用意してテストをするのでしょうか。
どちらのケースもあまり考えたくない状況です。
視点を変えて、ドメインサービスの UserService.IsDuplicated というメソッドの実装についてはどうでしょう。
public class UserService{ | |
public bool IsDuplicated(User user){ | |
var con = new MySqlConnection(Config.ConnectionString); // 接続文字列は static で定義されているとして | |
con.Open(); | |
using(var command = new MySqlCommand(con)){ | |
command.CommandText = "SELECT * FROM t_user WHERE username = @username"; | |
command.Parameters.Add(new MySqlParameter("@username", user.UserName.Value); | |
using(var reader = command.ExecuteReader()){ | |
var exist = reader.Read(); | |
return exist; | |
} | |
} | |
} | |
} |
結果として重複チェックをするだけのメソッドであっても、データの取得処理やそれに伴う準備と後始末が記述の大半を占めることになります。
データを取り扱う以上、データの保存処理等に関わる処理を記述することは避けられません。
しかし「ユーザを登録する」という処理において、データの保存処理はロジックの大半を占めるべき内容でしょうか。
「ユーザを登録する」というメソッドで表現すべきは
- 与えられたユーザ名でユーザを生成
- ユーザ名が重複しないかを確認する
- 重複した場合は例外を投げ、重複しなかった場合はユーザ登録をする
という内容です。
ドメイン駆動設計では勿論、ビジネスロジックがインフラストラクチャにまつわる準備や後始末に終始することをよしとしません。
しかしデータの保存処理はソフトウェアを作る上で無くてはならない機能です。
こうしたデータの保存処理をどのようにして取り扱えばよいのかを次の章の「データの永続化」で解説します。
データの永続化
データを生成したプログラムが終了しても、次にプログラムが起動したときにデータを利用できるようにソフトウェアは保存処理を行います。
こういった保存処理にまつわる事柄を「データの永続化」と表します。
データの永続化の対象は多くはエンティティが対象です。
ドメイン駆動設計においてどのようにエンティティを永続化させるのかをこの章で解説します。
リポジトリ
ドメイン駆動設計でデータの永続化の処理を行うのはリポジトリと呼ばれるオブジェクトです。
リポジトリはエンティティ毎(厳密には集約毎)に用意します。
SQL を用いたリポジトリ
早速ですが前章の SQL を利用した User オブジェクト用のリポジトリを作成してみましょう。
public class UserRepository { | |
public User Find(UserName username){ | |
using(var con = new MySqlConnection(Config.ConnectionString)) | |
using(var command = new MySqlCommand(con)){ | |
con.Open(); | |
command.CommandText = "SELECT * FROM t_user WHERE username = @username"; | |
command.Parameters.Add(new MySqlParameter("@username", username.Value); | |
using(var reader = command.ExecuteReader()){ | |
if(reader.Read()){ | |
var id = reader["id"] as string; | |
var userId = new UserId(id); | |
var user = new User(userid, username); | |
return user; | |
}else{ | |
return null; | |
} | |
} | |
} | |
} | |
public void Save(User user){ | |
using(var con = new MySqlConnection(Config.ConnectionString)){ | |
con.Open(); | |
using(var command = new MySqlCommand(con)){ | |
command.CommandText = "INSERT INTO t_user VALUES(@id, @username, @firstname, @familyname)"; | |
command.Paramters.Add(new MySqlParameter("@id", user.Id.Value); | |
command.Paramters.Add(new MySqlParameter("@username", user.UserName.Value); | |
command.Paramters.Add(new MySqlParameter("@firstname", user.Name.FirstName); | |
command.Paramters.Add(new MySqlParameter("@familyname", user.Name.FamilyName); | |
command.ExecuteNonQuery(); | |
} | |
} | |
} | |
} |
このリポジトリを使って、ユーザ登録をする処理を記述してみます。
まずはドメインサービスでリポジトリを利用するように修正します。
public class UserService{ | |
private readonly UserRepository userRepository; | |
public UserService(UserRepository userRepository){ | |
this.userRepository = userRepository; | |
} | |
public bool IsDuplicated(User user){ | |
var name = user.UserName; | |
var searched = userRepository.Find(name); | |
return searched != null; | |
} | |
} |
重複を確認するメソッドの IsDuplicated はリポジトリを利用するようになったので SQL を利用したデータベースを操作する処理が消え、「ユーザ名をキーにして検索し、存在していたら重複とする」という処理の意図が読み取りやすくなっています。
続いてメインの処理です。
public class Program{ | |
public void CreateUser(string username, string firstname, string familyname){ | |
var user = new User( | |
new UserName(username), | |
new FullName(firstname, familyname) | |
); | |
var userRepository = new UserRepository(); | |
var userService = new UserService(userRepository); | |
if(userService.IsDuplicated(user)){ | |
throw new Exception("重複しています"); | |
}else{ | |
userRepository.Save(user); | |
} | |
} | |
} |
リポジトリにデータの永続化に関わる処理を集中させることで、処理自体が何を目的にしているのかを際立てることができます。
テスト用リポジトリ
前章ではテストをするときにデータベースにデータを用意しなくてはいけないという欠点がありました。
これについては前項の SQL を用いたリポジトリでも同じことが起きています。
テスタビリティを確保するためにデータを用意しやすいリポジトリを作りましょう。
よくテスト用リポジトリでは連想配列が用いられます。
public class InMemoryUserRepository{ | |
private readonly Dictionary<UserId, User> data = new Dictionary<UserId, User>(); | |
public User Find(UserName name){ | |
return data.FirstOrDefault(x => x.UserName.Equals(name)); | |
} | |
public void Save(User user){ | |
data[user.Id] = user; | |
} | |
} |
リポジトリ名はメモリ上でのリポジトリを強調し InMemory をプレフィックスにしました。
保存先がデータベースからメモリに変更されているという違いはありますが、このクラスは User エンティティを検索したり、保存したりすることができます。
早速このリポジトリを組み込んでみましょう。
public class Program{ | |
public void CreateUser(string username, string firstname, string familyname){ | |
var user = new User( | |
new UserName(username), | |
new FullName(firstname, familyname) | |
); | |
var userRepository = new InMemoryUserRepository(); | |
var userService = new UserService(userRepository); | |
if(userService.IsDuplicated(user)){ | |
throw new Exception("重複しています"); | |
}else{ | |
userRepository.Save(user); | |
} | |
} | |
} | |
public class UserService{ | |
private readonly InMemoryUserRepository userRepository; | |
public UserService(InMemoryUserRepository userRepository){ | |
this.userRepository = userRepository; | |
} | |
public bool IsDuplicated(User user) { | |
var name = user.UserName; | |
var searched = userRepository.Find(name); | |
return searched != null; | |
} | |
} |
また UserService も InMemoryUserRepository を利用するように書き換えました。
ロジック自体は変更がなく問題なく動作しそうなものですが、InMemoryUserRepository にテストデータを準備することができない状態です。
テストデータを用意するには InMemoryUserRepository に初期データを用意する必要があります。
public class InMemoryUserRepository{ | |
private readonly Dictionary<UserId, User> map = new Dictionary<UserId, User>(); | |
public InMemoryUserRepository{ | |
// 初期データの準備 | |
var initialUser = new User( | |
new UserName("nrs"), | |
new FullName("nrs", "srn"); | |
); | |
Save(initialUser); | |
} | |
/* 省略 */ | |
public void Save(User user){ | |
map[user.Id] = user; | |
} | |
} |
ロジック毎のテストの度にプログラムを書き換えることは非常に手間です。
そもそも UserRepository を利用しているところ InMemoryUserRepository を使うように変更してテストすること自体が間違っています。
根底の問題は処理が特定の具象クラスに依存していることにあるようです。
依存
オブジェクト指向における依存とは、具象クラスへの依存を指します。
前項のプログラムの依存について見てみましょう。
public class Program{ | |
public void CreateUser(string username, string firstname, string familyname){ | |
var user = new User( | |
new UserName(username), | |
new FullName(firstname, familyname) | |
); | |
var userRepository = new UserRepository(); | |
var userService = new UserService(userRepository); | |
if(userService.IsDuplicated(user)){ | |
throw new Exception("重複しています"); | |
}else{ | |
userRepository.Save(user); | |
} | |
} | |
} |
- User
- UserRepository
- UserService
- Exception
このプログラムは UserRepository に依存しているため、処理を書き換えないとテストができない状態にあります。
テストを行えるようにするには UserRepository に依存しないように処理を書き換える必要があります。
具体的に実装していきましょう。
まずインターフェースを用意します。
public interface IUserRepository{ | |
User Find(UserName username); | |
void Save(User user); | |
} |
インターフェースがない言語の場合は完全抽象クラスで用意するとよいでしょう。
リポジトリインターフェースの用意ができたら、このインターフェースを引数で受け取るようにします。
public class Program{ | |
public void CreateUser(string username, string firstname, string familyname, IUserRepository userRepository){ | |
var user = new User( | |
new UserName(username), | |
new FullName(firstname, familyname) | |
); | |
var userService = new UserService(userRepository); | |
if(userService.IsDuplicated(user)){ | |
throw new Exception("重複しています"); | |
}else{ | |
userRepository.Save(user); | |
} | |
} | |
} | |
public class UserService{ | |
private readonly IUserRepository userRepository; | |
public UserService(IUserRepository userRepository){ | |
this.userRepository = userRepository; | |
} | |
public bool IsDuplicated(User user) { | |
var name = user.Name; | |
var searched = userRepository.Find(name); | |
return searched != null; | |
} | |
} |
しかしロジックの意図は伝わるかと思います。
UserRepository という具象クラスがロジック上から消え Program クラスは UserRepository に依存しないようになりました。
次にリポジトリにも修正が必要です。
と言っても処理自体はすでに作成してあるので、インターフェースを実装するように変更するだけです。
public class UserRepository : IUserRepository{ | |
/* 省略 */ | |
} | |
public class InMemoryUserRepository : IUserRepository{ | |
/* 省略 */ | |
} |
出来上がったクラスを利用してみましょう。
まずはプロダクションモードの場合のスクリプトです。
var program = new Program(); | |
var repository = new UserRepositor(); | |
program.CreateUser("ttaro", "taro", "tanaka", repository); |
この処理の場合データベースに “taro tanaka” さんが存在していたら失敗しますし、存在していなかったら登録に成功するでしょう。
続いてテストをするときの使い方です。
var program = new Program(); | |
var repository = new InMemoryUserRepository(); | |
var testData = new User(new FullName("taro", "tanaka")); | |
repository.Save(testData); | |
program.CreateUser("taro", "tanaka", repository); // Fail |
処理を実行する前にテストデータで “taro tanaka” さんを登録しているので、必ず登録に失敗することになります。
このように、具象クラスに依存せず、抽象リポジトリを引数などで受け取ることで、主処理を変更することなくロジックのテストを行うことができるようになるのです。
アプリケーションサービス
エンティティ、値オブジェクト、ドメインサービス、リポジトリの四つでロジックを表現するための最低限のパーツは揃いました。
いよいよパーツを組み立てる時が来たのです。
この章ではロジックを表現するドメインモデルを「誰が」扱うかに焦点をあてます。
ドメイン駆動設計ではドメインモデルを直接扱う要素として「アプリケーションサービス」というものを利用します。
リポジトリの節で作った Program というクラスはドメインモデルを直接扱っているのでアプリケーションサービスに近しい物でした。
ユーザ関連処理
アプリケーションサービスは API です。
モデルはドメイン領域を表現しますが、アプリケーションサービスはドメイン領域で何ができるのかを表現します。
まずはユーザに関して「何ができるのか」を表現したアプリケーションサービスを作ってみましょう。
今回作るアプリケーションはユーザ管理ツールとします。
アクターは管理者とし、管理者はユーザに関して以下の処理を行えます。
- ユーザの登録
- ユーザ情報変更
- ユーザの削除
- ユーザ情報取得
- ユーザ一覧取得
これらの項目をユースケースにすると以下のようになります。
ユーザの登録
早速定義していきます。
まずは「ユーザの登録」を記述します。
public class UserApplicationService{ | |
private readonly IUserRepository userRepository; | |
private readonly UserService userService; | |
public UserApplicationService(IUserRepository userRepository){ | |
this.userRepository = userRepository; | |
userService = new UserService(userRepository); | |
} | |
public void RegisterUser(string username, string firstname, string familyname){ | |
var user = new User( | |
new UserName(username), | |
new FullName(firstname, familyname) | |
); | |
if(userService.IsDuplicated(user)){ | |
throw new Exception("重複しています"); | |
}else{ | |
userRepository.Save(user); | |
} | |
} | |
} |
ロジック自体は前回書いたものと同じです。
ユーザ情報変更
ユーザ情報を変更する際にはどのユーザか特定できなくてはいけません。
ユーザはエンティティですので同一性を識別するための手段として UserId という識別子がありました。
この値を引数としてもらうようにしてみます。
public class UserApplicationService{ | |
private readonly IUserRepository userRepository; | |
private readonly UserService userService; | |
public UserApplicationService(IUserRepository userRepository){ | |
this.userRepository = userRepository; | |
userService = new UserService(userRepository); | |
} | |
public void RegisterUser(string username, string firstname, string familyname){ | |
/* 省略 */ | |
} | |
public void ChangeUserInfo(string id, string username, string firstname, string familyname) { | |
var targetId = new UserId(id); | |
var target = userRepository.Find(targetId); // interface に定義(下部に記載) | |
if (target == null) { | |
throw new Exception("not found. target id:" + id); | |
} | |
var newUserName = new UserName(username); | |
target.ChangeUserName(newUserName); | |
var newName = new FullName(firstname, familyname); | |
target.ChangeName(newName); | |
userRepository.Save(target); | |
} | |
} | |
public interface IUserRepository{ | |
User Find(UserId userId); // UserId で検索できるように追加 | |
User Find(FullName username); | |
void Save(User user); | |
} |
リポジトリインターフェースを実装しているクラスはコンパイルエラーになりますが、一旦他のメソッドの実装を優先しましょう。
ユーザの削除
現行のリポジトリは検索と保存しか処理が存在していないためユーザの削除が行えません。
そのためユーザの削除を実装する場合、リポジトリに削除処理を追加する必要があります。
public interface IUserRepository{ | |
User Find(UserId userId); | |
User Find(UserName username); | |
void Save(User user); | |
void Remove(User user); // 削除処理を追加 | |
} |
public class UserApplicationService{ | |
private readonly IUserRepository userRepository; | |
private readonly UserService userService; | |
public UserApplicationService(IUserRepository userRepository){ | |
this.userRepository = userRepository; | |
userService = new UserService(userRepository); | |
} | |
public void RegisterUser(string firstname, string familyname){ | |
/* 省略 */ | |
} | |
public void ChangeUserInfo(string id, string firstname, string familyname){ | |
/* 省略 */ | |
} | |
public void RemoveUser(string id){ | |
var targetId = new UserId(id); | |
var target = userRepository.Find(targetId); | |
if(target == null){ | |
throw new Exception("not found. target id:" + id); | |
} | |
userRepository.Remove(target); | |
} | |
} |
ユーザ情報取得
「ユーザ情報取得」はこれまでの処理と決定的に異なる部分があります。
それは戻り値が存在するという点です。
この戻り値ですがどのオブジェクトを返すべきでしょうか。
ユーザの情報そのものである User オブジェクトでしょうか。
プロジェクトのポリシーによりますが、この例ではドメイン領域の知識が流出しないように DTO (Data Transfer Object)を用意する方針で記述します。
public class UserModel{ | |
public UserModel(User source){ | |
Id = source.Id.Value; | |
UserName = source.UserName.Value; | |
Name = new FullNameModel(source.Name); | |
} | |
public string Id { get; } | |
public string UserName { get; } | |
public FullNameModel Name { get; } | |
} | |
public class FullNameModel { | |
public FullNameModel(FullName source){ | |
FirstName = source.FirstName; | |
FamilyName = source.FamilyName; | |
} | |
public string FirstName { get; } | |
public string FamilyName { get; } | |
} |
反対にデメリットは同じデータ構造のオブジェクトを生成することになるため、メモリ効率や変換処理の CPU 効率が悪くなることでしょう。
DTO を利用してユーザ情報を取得できるようにすると次のようになります。
public class UserApplicationService{ | |
private readonly IUserRepository userRepository; | |
private readonly UserService userService; | |
public UserApplicationService(IUserRepository userRepository){ | |
this.userRepository = userRepository; | |
userService = new UserService(userRepository); | |
} | |
/* 省略 */ | |
public UserModel GetUserInfo(string id){ | |
var userId = new UserId(id); | |
var target = userRepository.Find(userId); | |
if(target == null){ | |
return null; | |
} | |
return new UserModel(target); | |
} | |
} |
ユーザ一覧取得
管理者は任意のユーザを編集したり、削除処理を行います。
任意のユーザを操作するためには一覧でユーザを選択できるようにする必要があります。
ユーザ一覧も取得できるようにメソッドを用意しましょう。
public class UserApplicationService{ | |
private readonly IUserRepository userRepository; | |
private readonly UserService userService; | |
public UserApplicationService(IUserRepository userRepository){ | |
this.userRepository = userRepository; | |
userService = new UserService(userRepository); | |
} | |
public void RegisterUser(string firstname, string familyname){ | |
/* 省略 */ | |
} | |
public void ChangeUserInfo(string id, string firstname, string familyname){ | |
/* 省略 */ | |
} | |
public void RemoveUser(string id){ | |
/* 省略 */ | |
} | |
public List<UserSummaryModel> GetUserList(){ | |
var users = userRepository.FindAll(); | |
return users.Select(x => new UserSummaryModel(x)).ToList(); | |
} | |
} | |
public interface IUserRepository{ | |
User Find(UserId userId); | |
User Find(UserName username); | |
IEnumerable<User> FindAll(); // 全取得処理を追加 | |
void Save(User user); | |
void Remove(User user); | |
} | |
// 一覧用モデル | |
public class UserSummaryModel{ | |
public UserSummaryModel(User source){ | |
Id = source.Id.Value; | |
FirstName = source.UserName.Value; | |
} | |
public string Id { get; } | |
public string UserName { get; } | |
} |
サマリとして ID とユーザ名だけをデータとして渡しています。
リポジトリに追加した動作を実装
リポジトリインターフェースに処理を追加したので、本番用とテスト用のリポジトリに実装を追加しましょう。
プロダクション用リポジトリ
public class UserRepository : IUserRepository { | |
public User Find(UserId id) { | |
using (var con = new MySqlConnection(Config.ConnectionString)) { | |
con.Open(); | |
using (var com = con.CreateCommand()) { | |
com.CommandText = "SELECT * FROM t_user WHERE id = @id"; | |
com.Parameters.Add(new MySqlParameter("@id", id.Value)); | |
var reader = com.ExecuteReader(); | |
if (reader.Read()) { | |
var username = reader["username"] as string; | |
var firstname = reader["firstname"] as string; | |
var familyname = reader["familyname"] as string; | |
return new User( | |
id, | |
new UserName(username), | |
new FullName(firstname, familyname) | |
); | |
} else { | |
return null; | |
} | |
} | |
} | |
} | |
public User Find(UserName userName) { | |
using (var con = new MySqlConnection(Config.ConnectionString)) { | |
con.Open(); | |
using (var com = con.CreateCommand()) { | |
com.CommandText = "SELECT * FROM t_user WHERE username = @username"; | |
com.Parameters.Add(new MySqlParameter("@username", userName.Value)); | |
var reader = com.ExecuteReader(); | |
if (reader.Read()) { | |
var id = reader["id"] as string; | |
var firstname = reader["firstname"] as string; | |
var familyname = reader["familyname"] as string; | |
return new User( | |
new UserId(id), | |
userName, | |
new FullName(firstname, familyname) | |
); | |
} else { | |
return null; | |
} | |
} | |
} | |
} | |
public IEnumerable<User> FindAll() { | |
using (var con = new MySqlConnection(Config.ConnectionString)) { | |
con.Open(); | |
using (var com = con.CreateCommand()) { | |
com.CommandText = "SELECT * FROM t_user"; | |
var reader = com.ExecuteReader(); | |
var results = new List<User>(); | |
while (reader.Read()) { | |
var id = reader["id"] as string; | |
var firstname = reader["firstname"] as string; | |
var familyname = reader["familyname"] as string; | |
var username = reader["username"] as string; | |
var user = new User( | |
new UserId(id), | |
new UserName(username), | |
new FullName(firstname, familyname) | |
); | |
results.Add(user); | |
} | |
return results; | |
} | |
} | |
} | |
public void Save(User user) { | |
using (var con = new MySqlConnection(Config.ConnectionString)) { | |
con.Open(); | |
bool isExist; | |
using (var com = con.CreateCommand()) { | |
com.CommandText = "SELECT * FROM t_user WHERE id = @id"; | |
com.Parameters.Add(new MySqlParameter("@id", user.Id.Value)); | |
var reader = com.ExecuteReader(); | |
isExist = reader.Read(); | |
} | |
using (var command = con.CreateCommand()) { | |
command.CommandText = isExist | |
? "UPDATE t_user SET username = @username, firstname = @firstname, familyname = @familyname WHERE id = @id" | |
: "INSERT INTO t_user VALUES(@id, @username, @firstname, @familyname)"; | |
command.Parameters.Add(new MySqlParameter("@id", user.Id.Value)); | |
command.Parameters.Add(new MySqlParameter("@username", user.UserName.Value)); | |
command.Parameters.Add(new MySqlParameter("@firstname", user.Name.FirstName)); | |
command.Parameters.Add(new MySqlParameter("@familyname", user.Name.FamilyName)); | |
command.ExecuteNonQuery(); | |
} | |
} | |
} | |
public void Remove(User user) { | |
using (var con = new MySqlConnection(Config.ConnectionString)) { | |
con.Open(); | |
using (var command = con.CreateCommand()) { | |
command.CommandText = "DELETE FROM t_user WHERE id = @id"; | |
command.Parameters.Add(new MySqlParameter("@id", user.Id.Value)); | |
command.ExecuteNonQuery(); | |
} | |
} | |
} | |
} |
保存処理(Save)はデータが存在するかどうかによって挿入か更新かいずれかの動作が選択されて実行されます。
テスト用リポジトリ
public class InMemoryUserRepository : IUserRepository { | |
private readonly Dictionary<UserId, User> data = new Dictionary<UserId, User>(); | |
public User Find(UserId id) { | |
if (data.TryGetValue(id, out var target)) { | |
return target; | |
} else { | |
return null; | |
} | |
} | |
public User Find(UserName name) { | |
return data.Values.FirstOrDefault(x => x.UserName.Equals(name)); | |
} | |
public IEnumerable<User> FindAll() { | |
return data.Values; | |
} | |
public void Save(User user) { | |
data[user.Id] = user; | |
} | |
public void Remove(User user) { | |
data.Remove(user.Id); | |
} | |
} |
完成
ドメイン領域の表現(モデリング)とその API(アプリケーションサービス)が揃い、ドメイン駆動設計のパターンに倣った最小限のアプリケーションがこれで完成しました。
いよいよ次の章より実際にユーザが操作するアプリケーションと連携できるようにしていきます。
ユーザインターフェース
アプリケーションはユーザインターフェースと連携してユーザにその機能を提供します。
最小限のアプリケーションをユーザインターフェースに繋ぎこんで Web システムを完成させましょう。
サンプルアプリケーション
動作するサンプルアプリケーションを用意しています。
https://github.com/nrslib/BottomUpDDD
サンプルアプリケーションは ASP.NET Core で構成されています。
記事では触れないアプリケーションサービスのメソッドについても利用するように実装してあるので、もしよければご覧ください。
MVC フレームワーク
ユーザインターフェースとして MVC フレームワークを利用します。
コントローラ
アプリケーションサービスを利用するのはコントローラです。
public class UserController : Controller { | |
private readonly UserApplicationService userService; | |
public UserController(UserApplicationService userService){ | |
this.userService = userService; | |
} | |
} |
このように特定のオブジェクトをコントローラで受け取るためには Dependency Injection という手法を利用します。
Dependency Injection
Dependency Injection は単純に言えばコンストラクタでインスタンスを受け取る程度の意味です。
つまりコンストラクタでインスタンスを受け取っていれば Dependency Injection であると言えます。
焦点となるのは「どのオブジェクトがコンストラクタで渡されるのか」という点です。
MVC フレームワークではコントローラはフレームワーク部分で生成されます。
つまりプログラマは任意のコンストラクタを実行することができません。
コントローラのコンストラクタで特定のオブジェクトを受け取るためには予めどのオブジェクトを受け取るかの設定をする必要があります。
この設定で利用されるのが DIContainer と呼ばれるもので、多くの MVC フレームワークではこの仕組みが備わっています。
サンプルプロジェクトでは StartUp.cs で設定されています。
public class Startup | |
{ | |
public Startup(IConfiguration configuration) | |
{ | |
Configuration = configuration; | |
} | |
public IConfiguration Configuration { get; } | |
// This method gets called by the runtime. Use this method to add services to the container. | |
public void ConfigureServices(IServiceCollection services) | |
{ | |
services.AddMvc(); | |
services.AddSingleton<IUserRepository, InMemoryUserRepository>(); // IUserRepository を要求されたら InMemoryUserRepository を引き渡す | |
services.AddTransient<UserApplicationService>(); // UserApplicationService を要求されたらそのままインスタンス化する | |
} | |
} |
- IUserRepository が要求される個所 → InMemoryUserRepository
- UserApplicationService が要求される個所 → UserApplicationService
という関係でオブジェクトがインスタンス化されて引き渡されます。
また InMemoryUserRepository はシングルトンでの登録がされているので Web アプリケーション起動中は一つのインスタンスが使いまわされます。
反対に UserApplicationService はリクエストごとに毎回インスタンスが生成されます。
ユーザ一覧の取得処理
アプリケーションサービスをコントローラが受け取ることができたら後は利用するだけです。
Index ページでユーザの一覧を取得して View にデータを渡してみましょう。
public class UserController : Controller { | |
private readonly UserApplicationService userService; | |
public UserController(UserApplicationService userService) { | |
this.userService = userService; | |
} | |
public IActionResult Index() { | |
var users = userService.GetUserList(); | |
var summaries = users.Select(x => new UserSummaryViewModel { | |
Id = x.Id, | |
UserName = x.UserName | |
}); | |
return View(summaries); | |
} | |
} |
これで繋ぎこみが完了です。
最小限の Web システムが完成しました。
単体テスト
ドメイン駆動設計のパターンに従って構築をするとテストも行いやすくなります。
早速テストを書いてみましょう。
テストを書く単位はアプリケーションサービスのメソッドを単位としてテストを記述すると、ちょうどビジネスロジック毎のテストになってよいかと思います。
今回は「ユーザ登録」についてのテストを記述してみます。
[TestClass] | |
public class UserApplicationRegisterUserTest { | |
[TestMethod] | |
public void TestDuplicateFail() { | |
var repository = new InMemoryUserRepository(); | |
var username = new UserName("ttaro"); | |
var fullname = new FullName("taro", "tanaka"); | |
repository.Save(new User(username, fullname)); | |
var app = new UserApplicationService(repository); | |
bool isOk = false; | |
try { | |
app.RegisterUser("ttaro", "taro", "tanaka"); | |
} catch (Exception e) { | |
if (e.Message.StartsWith("重複")) { | |
isOk = true; | |
} | |
} | |
Assert.IsTrue(isOk); | |
} | |
[TestMethod] | |
public void TestRegister() { | |
var repository = new InMemoryUserRepository(); | |
var app = new UserApplicationService(repository); | |
app.RegisterUser("ttaro", "taro", "tanaka"); | |
} | |
} |
ロジックだけのテストであれば、このようにインメモリのリポジトリを用意して都合のよいデータを用意すればテストを行えます。
中間地点
最小限の Web システムが完成しました。
テストも用意することができました。
切りもよいのでここで一旦の区切りとします。
ここまでで出てきていないパターンやいくつかの課題が残っています。
またドメイン駆動設計の根底にある考え方については全く触れていません。
しかしドメイン駆動設計の片鱗に触れることはできたのではないでしょうか。
残りのパターンやいくつかの課題(トランザクションの扱いや ID を独自の採番の仕組みにしたいとき等)を解決するための方法についても引き続き記事を作っていこうと思いますが、今はここまでです。
長文にお付き合いいただきありがとうございました。
後編書きました。
https://nrslib.com/bottomup-ddd-2/
ドメイン駆動設計系の記事リンク
この記事を書く前に個別の項目で記事を書いていました。
しかしそれぞれ個別の記事だと繋がりがわかりづらいなーと考えてこの記事を作った次第です。
もし興味があればどうぞ。
◆ ValueObject
記事リンク: https://nrslib.com/valueobject/
◆ Entity
記事リンク: https://nrslib.com/entity/
◆ AggregateRoot
記事リンク: https://nrslib.com/aggregateroot/
◆ Repository
記事リンク: https://nrslib.com/repository/