-
単純すぎるドメイン駆動設計Javaサンプルコード
- 単純すぎるドメイン駆動設計Javaサンプルコード (1) DDDらしいコード
- 単純すぎるドメイン駆動設計Javaサンプルコード (2) 仕様追加その1
- 単純すぎるドメイン駆動設計Javaサンプルコード (3) 仕様追加その2 <- ここ
- 単純すぎるドメイン駆動設計Javaサンプルコード (4) テスト
さらに、以下の要件が追加されたとします。
- ユーザごとに足し算の履歴を保存できる
今回の例ではコマンドラインインターフェースとしているので、標準入力で複数回足し算を実行できるように変更し、かつ、ユーザごとに <ユーザ名>.csv
のかたちで履歴をファイル出力するようにします。
ここでも、手続き型設計の場合とDDDの場合で、どのような違いが生まれるかを見ていきます。
手続き型設計の場合
以下の変更を行いました。
- 標準入力を1行ずつ読み取って足し算を実行するように変更
- ユーザ名を指定するための引数を追加
- 足し算の履歴をファイル出力する処理を追加
さすがにちょっと長くなって見通しが悪くなってきました。
public class TransactionAddition03 {
public static void main(String[] args) {
System.out.println("'username 1 2'のように入力すると、username.csvに'1,2,3'(1つ目の値,2つ目の値,足し算結果)と出力します");
System.out.println("'q'を入力すると終了します");
// 複数回実行できるように変更
Scanner sc = new Scanner(System.in);
while (sc.hasNextLine()) {
String[] lineArgs = sc.nextLine().split(" ");
execute(lineArgs);
}
}
private static void execute(String[] args) {
// 終了コマンド
if (args[0].equals("q")) {
System.out.println("終了します");
System.exit(0);
}
// 引数チェック
if (args.length != 3) {
System.err.println("引数を3つ指定してください");
System.exit(1);
}
// 引数にユーザ名を追加
String username = args[0];
int param1 = 0;
int param2 = 0;
try {
param1 = Integer.parseInt(args[1]);
param2 = Integer.parseInt(args[2]);
} catch (NumberFormatException e) {
System.err.println("整数を指定してください");
System.exit(1);
}
if (param1 < 0 || param2 < 0) {
System.err.println("負の数は扱いません");
System.exit(1);
}
// 足し算を実行
int result = param1 + param2;
// 履歴をファイル出力
String filename = username + ".csv";
try(FileWriter fw = new FileWriter(filename, true)) {
fw.write(String.format("%s,%s,%s%n", param1, param2, result));
} catch (IOException ex) {
ex.printStackTrace();
System.exit(1);
}
// 結果を表示
System.out.printf("%s + %s = %s%n", param1, param2, result);
}
}
DDDの場合
- ユーザごとに足し算の履歴を保存できる
追加の要件から、新たなドメインモデルを抽出し、ドメイン層に以下のクラスを追加します。
ドメイン層
User
- ユーザは名前を持ち、名前によって識別される
- 普通はユーザID的なもので識別すると思いますが、単純にするため名前にしてます。
- ユーザは足し算の履歴を保持する
- 履歴は実行した足し算の式の集合である
- ユーザの足し算の履歴として、足し算の式を1つずつ追加することができる
public class User {
private final String name; // 不変で一意な識別子
private final List<AdditionFormula> history = new ArrayList<>(); // 可変の属性(List参照は不変だが中身は可変)
public User(String name) {
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User)) return false;
User user = (User) o;
return Objects.equals(this.name, user.name); // 名前で識別する
}
@Override
public int hashCode() {
return Objects.hash(name);
}
public String getName() {
return this.name;
}
public List<AdditionFormula> getHistory() {
return new ArrayList<>(this.history);
}
public void addHistory(AdditionElement e1, AdditionElement e2, AdditionElement result) {
this.history.add(new AdditionFormula(e1, e2, result));
}
}
このUser
は、DDDにおいて「エンティティ」に分類されるモデルとなります。
エンティティは一意に識別して変更を管理できるものであり、その特徴ゆえに永続化の対象となることが多いです。
ここでは、ユーザは名前によって一意に識別できるものとしています。足し算の履歴が増えていっても、そのユーザが同じユーザであることに変わりはありません。名前が異なれば別ユーザとして扱われ、履歴も別管理されます。
User
のプロパティのうち、識別子である名前(name
)が不変であるのに対し、履歴(history
)は可変となっています。(履歴のリスト自体はfinalで不変となっていますが、リストの中身は追加可能なので可変とみなしています。)
また、 equals
と hashCode
メソッドをオーバーライドし、 name
が同じであることのみを、同一性の条件としています。 値オブジェクトでも同様に equals
と hashCode
メソッドをオーバーライドしていましたが、エンティティとは目的が異なるので注意が必要です。値オブジェクトでは各属性が持つすべての値が同じかで等価性を判定しますが、エンティティでは一意な識別子の値が同じかで同一性を判定します。
AdditionFormula
- 足し算の式は、足し算の要素2つと、足し算の結果を含む
public class AdditionFormula {
private final AdditionElement element1;
private final AdditionElement element2;
private final AdditionElement result;
public AdditionFormula(AdditionElement e1, AdditionElement e2, AdditionElement result) {
this.element1 = e1;
this.element2 = e2;
this.result = result;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof AdditionFormula)) return false;
AdditionFormula that = (AdditionFormula) o;
return Objects.equals(element1, that.element1) && Objects.equals(element2, that.element2) && Objects.equals(result, that.result);
}
@Override
public int hashCode() {
return Objects.hash(element1, element2, result);
}
public int getElement1Value() {
return this.element1.getValue();
}
public int getElement2Value() {
return this.element2.getValue();
}
public int getResultValue() {
return this.result.getValue();
}
}
こちらは値オブジェクトです。
振る舞いらしいメソッドを持ってないのでドメインモデルとしては微妙ですが、足し算における要素2つと結果のひとまとまりを表す概念としてモデル化しています。
UserRepository
- ユーザを保存することができる
- 名前を指定してユーザを取得することができる
public interface UserRepository {
void save(User user) throws UserRepositoryException;
User find(String username) throws UserRepositoryException;
}
この UserRepository
は、DDDにおいて「リポジトリ」に分類されるモデルの抽象インタフェースです。
リポジトリは簡単に言ってしまえばエンティティを永続化する場所です。(トランザクションの単位である「集約」を永続化すると言った方がよいかもしれませんが、今回の単純な例では単一の User
エンティティ=集約ルートエンティティとなるので、いったん詳細は割愛します。)
この例ではJavaのinterfaceを使用して抽象として定義されており、ファイル出力することによって保存する、という具体的な処理はインフラストラクチャ層の以下のクラスで実装しています。
インフラストラクチャ層
public class UserRepositoryOnFile implements UserRepository {
public void save(User user) throws UserRepositoryException {
String filename = user.getName() + ".csv";
try(FileWriter fw = new FileWriter(filename)) {
for (AdditionFormula formula : user.getHistory()) {
String[] arr = {
String.valueOf(formula.getElement1Value()),
String.valueOf(formula.getElement2Value()),
String.valueOf(formula.getResultValue())
};
String csv = String.join(",", arr);
fw.write(csv + "\n");
}
} catch (IOException e) {
throw new UserRepositoryException(e);
}
}
public User find(String name) throws UserRepositoryException {
User user = new User(name);
File file = new File("./" + name + ".csv");
if (file.exists()) {
try {
List<List<String>> records = readCsvFile(file);
addHistoryTo(user, records);
} catch (IOException e) {
throw new UserRepositoryException(e);
}
}
return user;
}
private List<List<String>> readCsvFile(File file) throws IOException {
BufferedReader br = new BufferedReader(new FileReader(file));
List<List<String>> records = new ArrayList<>();
String line;
StringTokenizer token;
while ((line = br.readLine()) != null) {
List<String> record = new ArrayList<>();
token = new StringTokenizer(line, ",");
while (token.hasMoreTokens()) {
record.add(token.nextToken());
}
records.add(record);
}
br.close();
return records;
}
private void addHistoryTo(User user, List<List<String>> records) {
records.forEach(record -> {
user.addHistory(
new AdditionElement(Integer.parseInt(record.get(0))),
new AdditionElement(Integer.parseInt(record.get(1))),
new AdditionElement(Integer.parseInt(record.get(2)))
);
});
}
}
ファイル入出力まわりは少し記述が多くてごちゃごちゃしてしまいましたが、その部分はここでは重要ではありません。
ここでは抽象と実装を分離していることが、ドメインの隔離において重要となります。
SOLID原則のD(Dependency Inversion Principle 依存関係逆転の原則)により、利用の方向(ドメイン→インフラストラクチャ)と依存の方向(インフラストラクチャ→ドメイン)を逆にしていることで、ドメイン層の独立性(他のどの層にも依存しない状態)を維持しています。
出力先がファイルだろうがDBだろうがメモリだろうが、ドメイン層は気にすることなく同じ save
メソッドによって保存することができます。出力方法を変えたければ、 UserRepository
を実装するインフラストラクチャ層のクラスを差し替えればよいのです。
ユースケース層
ユースケース層のアプリケーションサービスの変更は以下となります。
- ユーザリポジトリによりユーザを取得・保存する処理を追加
- ユーザリポジトリをコンストラクタ引数で受け取り、DIできるよう修正
public class AdditionService {
private final UserRepository userRepository;
public AdditionService(UserRepository userRepository){
this.userRepository = userRepository; // リポジトリ実装をDI
}
public int execute(String username, int int1, int int2) throws AdditionServiceException {
try {
// ドメインオブジェクトを生成
final AdditionElement e1 = new AdditionElement(int1);
final AdditionElement e2 = new AdditionElement(int2);
// ドメインオブジェクトに処理を委譲
final AdditionElement result = e1.plus(e2);
final User user = this.userRepository.find(username);
user.addHistory(e1, e2, result); // エンティティを更新
this.userRepository.save(user); // エンティティをリポジトリで永続化
return result.getValue();
} catch (IllegalArgumentException | UserRepositoryException e) {
throw new AdditionServiceException(e);
}
}
}
UI層
UI層は、以下の修正を行います。
- 足し算を複数回実行できるように変更(手続き型設計と同じ)
- ファイル出力を行うユーザリポジトリ実装をアプリケーションサービスにDI
public class DDDAddition03 {
public static void main(String[] args) {
System.out.println("'username 1 2'のように入力すると、username.csvに'1,2,3'(1つ目の値,2つ目の値,足し算結果)と出力します");
System.out.println("'q'を入力すると終了します");
// 複数回実行できるように変更
Scanner sc = new Scanner(System.in);
while (sc.hasNextLine()) {
String[] lineArgs = sc.nextLine().split(" ");
execute(lineArgs);
}
}
private static void execute(String[] args) {
// 終了コマンド
if (args[0].equals("q")) {
System.out.println("終了します");
System.exit(0);
}
// 引数チェック
if (args.length != 3) {
System.err.println("引数を3つ指定してください");
System.exit(1);
}
// 引数にユーザ名を追加
String username = args[0];
int param1 = 0;
int param2 = 0;
try {
param1 = Integer.parseInt(args[1]);
param2 = Integer.parseInt(args[2]);
} catch (NumberFormatException e) {
System.err.println("整数を指定してください");
System.exit(1);
}
// アプリケーションサービスを生成・実行
UserRepository userRepository = new UserRepositoryOnFile(); // 履歴をファイル出力するリポジトリ実装を使用
AdditionService service = new AdditionService(userRepository); // リポジトリ実装をDI
int result = 0;
try {
result = service.execute(username, param1, param2);
} catch (AdditionServiceException e) {
System.err.println("足し算サービスでエラーが発生しました");
e.printStackTrace();
System.exit(1);
}
// 結果を表示
System.out.printf("%s + %s = %s%n", param1, param2, result);
}
}
ここでポイントとなるのはリポジトリ実装のDI(Dependency Injection)です。
今回は UserRepository
インターフェースの実装クラスとして、ファイル出力を行う UserRepositoryOnFile
というクラスを実装し、アプリケーションサービスに渡していますが、例えばファイルの代わりにDBを使いたくなった場合は、DBアクセスする実装クラス UserRepositoryOnDB
を別途用意して、 UserRepositoryOnFile
の代わりに渡せばいいだけです。その際、アプリケーション層やドメイン層を変更する必要はありません。アプリケーション層やドメイン層はインフラストラクチャ層のクラスに依存していないからです。