はじめに
本記事では、ドメイン駆動設計のパターンとして用いられるアプリケーションサービスについて、初心者向けに解説したものです。
対象読者層
- ドメイン駆動設計における、アプリケーションサービスがわからない方
前提条件
- ドメイン駆動設計における以下パターンの概念について理解していること
- 値オブジェクト
- エンティティ
- ドメインサービス
結論
- アプリケーションサービスは、複数のドメインオブジェクトを操作して特定のユースケースを実現する役割を持つ
- ドメインオブジェクト(値オブジェクト・エンティティ・ドメインサービス)は、ドメイン知識を表現した部品
- アプリケーションサービスは、これらの部品を取りまとめてユースケースを実現する
なぜアプリケーションサービスを採用するのか?
アプリケーションサービスを採用する理由は、大きく2つあります
- 特定のユースケースを明確にするため
- ビジネスロジックの分離
それぞれ見ていきましょう。
特定のユースケースを明確にするため
ユースケースとは、システムやソフトウェアがどのように利用されるかを、ユーザーの視点から記述したものです。
SNSを例にすると、以下のようなユースケースが挙げられます。
- アカウントを登録する
- プロフィールを参照する
- アカウントのプロフィールを更新する
- アカウントを削除する
各ユースケースでは、ユースケースを実現する上で重要となるドメイン知識が存在します。
例えば、「アカウントを登録する」ユースケースでは、以下がドメイン知識となりえます。
- アカウントを登録するには、メールアドレス・パスワードを設定する必要がある
- パスワードは、半角英数+記号を含めた6文字以上である必要がある
ドメインオブジェクト(ドメイン知識)だけでは、システムは完成しない
これまでの記事では、これらのドメイン知識を表現するためにドメインオブジェクトがあることを学びました。
しかし、ドメインオブジェクトはユースケースを実現するための部品です。
部品があるだけでは、システムは動作しません。
各ドメインオブジェクトを組み合わせて「アカウントを登録する」ユースケースを実現する必要があります。
ここで登場するのが、アプリケーションサービスです。
アプリケーションサービスの役割は、各ドメインオブジェクトを組み合わせてユースケースを実現することです。
この関係をパソコンで例えるなら、
CPU・GPU・空冷ファン等の部品だけでは、パソコンは完成しません
これらの部品を組み立ててパソコンをつくる人が必要になります
コンポーネント | 役割 |
---|---|
CPU・GPU・冷却ファン等の部品 | ドメインオブジェクト |
部品を組み立ててパソコンをつくる人 | アプリケーションサービス |
ビジネスロジックの分離
アプリケーションサービスは、ビジネスロジックを書かない特徴があります。
ビジネスロジックはドメインオブジェクトに任せます。
これにより、アプリケーションサービスは複数のドメインオブジェクトを組み合わせる調整役に集中させることができます。
このメリットは、単一責任原則に通ずるところがありますね。
アプリケーションサービスのサンプルコード
以下は、SNSでの「アカウントを登録する」ユースケースを行うサンプルコードです。
アプリケーションサービス
/**
* アプリケーションサービス(ユースケースの実装)
*/
class AccountApplicationService {
private final AccountDomainService domainService;
public AccountApplicationService(AccountDomainService domainService) {
this.domainService = domainService;
}
/**
* アカウントを登録する
* @param email メールアドレス
* @param password パスワード文字列
*/
public void registerAccount(String email, String password) {
if (domainService.isEmailAlreadyRegistered(email)) {
throw new IllegalArgumentException("このメールアドレスは既に登録されています。");
}
Account account = new Account(email, new Password(password));
domainService.registerNewAccount(account);
}
ドメインオブジェクト
ドメインサービス
/**
* ドメインサービス(ビジネスルールのカプセル化)
*/
class AccountDomainService {
private final AccountRepository repository;
public AccountDomainService(AccountRepository repository) {
this.repository = repository;
}
/**
* メールアドレスが既に登録されているかをチェックする
* @param email メールアドレス
* @return 登録済みの場合はtrue
*/
public boolean isEmailAlreadyRegistered(String email) {
return repository.findByEmail(email).isPresent();
}
/**
* 新しいアカウントを登録する
* @param account 登録するアカウント
*/
public void registerNewAccount(Account account) {
repository.save(account);
}
}
エンティティ
/**
* エンティティ(アカウント)
*/
class Account {
private final String email;
private final Password password;
/**
* アカウントを作成する
* @param email メールアドレス
* @param password パスワードオブジェクト
*/
public Account(String email, Password password) {
this.email = email;
this.password = password;
}
/**
* メールアドレスを取得する
* @return メールアドレス
*/
public String getEmail() {
return email;
}
/**
* パスワードを取得する
* @return パスワードオブジェクト
*/
public Password getPassword() {
return password;
}
}
値オブジェクト
/**
* 値オブジェクト(不変のパスワード)
*/
class Password {
private static final Pattern VALID_PATTERN = Pattern.compile("^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[!@#$%^&*]).{6,}$");
private final String value;
/**
* パスワードのバリデーションを行い、不正な場合は例外をスローする
* @param value パスワード文字列
*/
public Password(String value) {
if (!VALID_PATTERN.matcher(value).matches()) {
throw new IllegalArgumentException("パスワードは半角英数字と記号を含む6文字以上である必要があります。");
}
this.value = value;
}
/**
* パスワードの値を取得する
* @return パスワード文字列
*/
public String getValue() {
return value;
}
}
リポジトリ
/**
* リポジトリ(永続化のインターフェース)
*/
interface AccountRepository {
/**
* メールアドレスからアカウントを検索する
* @param email メールアドレス
* @return アカウント(存在しない場合はnull)
*/
Optional<Account> findByEmail(String email);
/**
* アカウントを保存する
* @param account 保存するアカウント
*/
void save(Account account);
}
AccountRepositoryインターフェースを実装したクラスのコードは、サンプルコードの簡潔さを優先して省略しています。
直接ビジネスロジックを書かない
アプリケーションサービスは、ユースケースを表現する目的で採用されるため他の場所から再利用がしづらいです。
悪い例として、「登録済みメールアドレスかチェックする」ロジックを、registerAccount()メソッド内で直接実施するように変更してみます。
/**
* アカウントを登録する(悪い例)
* @param email メールアドレス
* @param password パスワード文字列
*/
public void registerAccount(String email, String password) {
// メールアドレスの登録済みチェックをここで直接実行(悪い例)
if (repository.findByEmail(email).isPresent()) {
throw new IllegalArgumentException("このメールアドレスは既に登録されています。");
}
Account account = new Account(email, new Password(password));
repository.save(account);
}
仮に、他の場所からも「登録済みメールアドレスかチェックする」処理が必要になった場合、
registerAccount()メソッドを再利用することはできません。
「登録済みメールアドレスかチェックする」処理だけをやりたいのに、アカウントの登録処理まで行われてしまうためです。
これは、他の場所でも「登録済みメールアドレスかtする」処理を書いてしまう原因になります。
※同じコードが複数箇所に書かれてしまう
再利用しやすいドメインオブジェクトに設計することで、コードの重複を回避できます。
アプリケーションクラスは、あくまで複数の部品(ドメインオブジェクト)を組み立てるのが仕事です。
自ら部品を作って使うようなことをしてはいけません。
複数のユースケースを持ちすぎない
ユーザー登録のユースケースが作成できたら、同じように更新・削除のユースケースも追加したくなります。
しかし、これはありがちな悪い設計例になってしまいます。
クラスの肥大化につながってしまうためです。
/**
* アカウントを登録する
*/
public void registerAccount(String email, String password) {
// 省略
}
/**
* アカウントを更新する
*/
public void updateAccount(String userId) {
// 省略
}
/**
* アカウントを削除する
*/
public void deleteAccount(String userId) {
// 省略
}
ソースコードが肥大化していくとクラスの責務も曖昧になります。
AccountApplicationServiceクラスを実装したばかりの頃は、アカウントに関するCRUD処理だけ定義されているかもしれません。
しかし、時間が経ち、このクラスを実装した担当者もいなくなっていくと、そのルールは守られなくなる可能性があります。
気づいた時には、アカウントに関連するユースケースすべてを取り扱うまでにクラスが巨大化してしまうことも十分ありえます。
こういった状況を防ぐために、よほどのことがない限り1クラスで表現するユースケースは1つまでにするほうが安全です。
そこで重要になってくるのが、クラスの命名です。
AccountApplicationService というクラス名は汎用的です。
そのため、他のユースケースまで追加しても問題なさそうに見えてしまいます。
そこで、クラス名をAccountRegisterApplicationServiceに変更しましょう。
こうすることで、設計者がアカウント登録以外のユースケースをこのクラスに定義するには違和感を与えることができます。
クラス名を具体的なものにすることで、クラスの責務をシンプルにすることができるのです。