ドメインモデルの完全性と純粋性
ドメインモデルには、完全性と純粋性、そしてアプリケーション性能の3つ全てを同時に満足させることは難しい場合があるという話。
各評価軸とそれを満たすことによって得られるメリットの一般的な見方は以下のとおり。
完全性: ドメイン層の責務(ビジネスルール、モデルの不変条件など)が、ドメインオブジェクトに閉じている。
完全性が守られることによって、高凝集・疎結合が達成され、コードの変更容易性が向上する。
純粋性: ドメインモデルが、別のレイヤーのオブジェクト・副作用のあるオブジェクトに依存していない。
純粋性は主にテスト容易性と移植性に効いてくる。モックを使わずにスローテストじゃない(1ケース1秒以上かからない)テストケースが書けるかが、そのドメインモデルが純粋かどうかの1つの見分け方になる。
アプリケーション性能
少なくとも現実的な範囲でデータ量が増加しても、サービスに大きな影響を与えない程度の応答時間、リソース使用量に収まること。
例
以下のようなUserのドメインオブジェクトについて、Emailアドレスを変更を例として考える。
code:User.java
import io.fries.result.Result;
import lombok.Getter;
public class User {
@Getter
Company company;
@Getter
String email;
public Result<User> changeEmail(String newEmail) {
if (!company.isEmailCorporate(newEmail)) {
return Result.error(new EmailNotCorporateException(company.getCorporateDomain()));
}
this.email = newEmail;
return Result.ok(this);
}
}
code:Company.java
import lombok.Getter;
public class Company {
@Getter
private final String corporateDomain;
private Company(String domain) {
this.corporateDomain = domain;
}
public boolean isEmailCorporate(String email) {
if (email == null) return false;
return email.endsWith(corporateDomain);
}
}
これを使うアプリケーションのコードは、以下のように書ける
code:ChangeUserEmailHandler.java
import io.fries.result.Result;
import lombok.Value;
import org.springframework.stereotype.Component;
/**
* ドメインモデルの例
*/
@Component
public class ChangeUserEmailHandler {
@Value
public static class ChangeUserEmailCommand {
int userId;
String newEmail;
}
@Value
public static class ChangedUserEmailEvent {
int userId;
String oldEmail;
String newEmail;
}
private final LoadUserPort loadUserPort;
private final SaveUserPort saveUserPort;
public ChangeUserEmailHandler(LoadUserPort loadUserPort, SaveUserPort saveUserPort) {
this.loadUserPort = loadUserPort;
this.saveUserPort = saveUserPort;
}
public Result<ChangedUserEmailEvent> changeEmail(ChangeUserEmailCommand command) {
User user = loadUserPort.load(command.getUserId()).orElseThrow();
String oldEmail = user.getEmail();
Result<User> result = user.changeEmail(command.getNewEmail());
if (result.isError()) {
return Result.error(result.getError());
}
saveUserPort.save(user);
return Result.ok(new ChangedUserEmailEvent(
command.getUserId(),
oldEmail,
command.getNewEmail()
));
}
}
この例でのドメインモデルは、すべてのビジネスルールはUserおよびCompanyに実装され閉じており、別のレイヤーにも依存しておらず副作用もないので、完全かつ純粋である。また、アプリケーション性能も現実的なものになるだろう。
なので、この時点では3つを同時に満たすことができる。
ここでEメールアドレス変更時のチェックに1つ条件が追加され、「変更しようとしているメードアドレスが他のどのユーザとも重複してないこと」を担保しなければならないとする。
これは、Eメールアドレス変更前に、重複がないかデータベースアクセスして調べてくれるExistsEmailPortを介して、そのチェック処理を入れることで、目論見通りうまくいくだろう。
code:ChangeUserUniqueEmailHandler.java
public Result<ChangeUserEmailHandler.ChangedUserEmailEvent> changeEmail(ChangeUserEmailHandler.ChangeUserEmailCommand command) {
if (existsEmailPort.exists(command.getNewEmail())) {
return Result.error(new EmailAlreadyTakenException(command.getNewEmail()));
}
User user = loadUserPort.load(command.getUserId()).orElseThrow();
String oldEmail = user.getEmail();
Result<User> result = user.changeEmail(command.getNewEmail());
if (result.isError()) {
return Result.error(result.getError());
}
saveUserPort.save(user);
return Result.ok(new ChangeUserEmailHandler.ChangedUserEmailEvent(
command.getUserId(),
oldEmail,
command.getNewEmail()
));
}
だが、この変更後のコードは、「重複がないこと」というビジネスルールの実装がドメイン層ではなく、アプリケーション層で実装されているので、完全性が失われている。
それでは、とExistsEmailPortをドメインオブジェクトに渡すようにしたらどうか。
code:ChangeUserUniqueEmailByPortHandler.java
public Result<ChangeUserEmailHandler.ChangedUserEmailEvent> changeEmail(ChangeUserEmailHandler.ChangeUserEmailCommand command) {
User user = loadUserPort.load(command.getUserId()).orElseThrow();
String oldEmail = user.getEmail();
Result<User> result = user.changeEmail(command.getNewEmail(), existsEmailPort);
if (result.isError()) {
return Result.error(result.getError());
}
saveUserPort.save(user);
return Result.ok(new ChangeUserEmailHandler.ChangedUserEmailEvent(
command.getUserId(),
oldEmail,
command.getNewEmail()
));
}
code:User.java
public Result<User> changeEmail(String newEmail, ExistsEmailPort existsEmailPort) {
if (existsEmailPort.exists(newEmail)) {
return Result.error(new EmailAlreadyTakenException(newEmail));
}
if (!company.isEmailCorporate(newEmail)) {
return Result.error(new EmailNotCorporateException(company.getCorporateDomain()));
}
this.email = newEmail;
return Result.ok(this);
}
ビジネスルールはすべてUserで実装されることになり完全性は確保されたが、今度はドメインがExistsEmailPortに依存することになり純粋性が失われる。
このケースでも、アプリケーションの性能を犠牲にすれば完全性と純粋性の両立は可能だ。つまり、アプリケーション層ですべてのUserデータをリストとして取得し、UserのchangeEmailに渡し、その中で重複チェックを行えばよい。だが、実用的でないことは一目瞭然だろう。
code:User.java
public Result<User> changeEmail(String newEmail, List<User> allUsers) {
if (allUsers.stream().anyMatch(user -> user.getEmail().equals(newEmail))) {
return Result.error(new EmailAlreadyTakenException(newEmail));
}
if (!company.isEmailCorporate(newEmail)) {
return Result.error(new EmailNotCorporateException(company.getCorporateDomain()));
}
this.email = newEmail;
return Result.ok(this);
}
というように、アプリケーション性能を保ったままで、完全性を満たしにいくと純粋性が犠牲になり、完全性を満たしにいくと純粋性が犠牲になり、純粋性を満たしにいくと完全性が犠牲になる。アプリケーション性能を度外視すれば、完全性と純粋性の両立は可能だが実用的ではない。
したがって、どの2つを取りに行くかの設計上の選択が必要だ。
どれを優先すべきか?
著者のvkhorikovさんの(強い)おすすめは、完全性よりも純粋性を優先させることである。
複雑なビジネスロジックを他の雑念を退けて、あるレイヤに押し込めるためにドメイン層を用意するのに、ビジネスロジック以外の責務が混ざると、余計な複雑性が増すからである。
バリデーションにおける完全性と純粋性
TODO
コード例の全量は以下のリポジトリにあります。