はじめに
前回、データベーススペシャリストの問題を使ってDDDでサンプルプログラムを作ってみたという記事を書きました。
ドメイン駆動設計でデータベーススペシャリストの問題を使ってサンプルプログラムを作ってみた
コメント欄やtwitterでコメントしてくださった方々ありがとうございました。
上手くいかなかった部分も多かったですし、改善してアウトプットし続けることが大事だと思いますので、リファクタリングした記事を投稿しようと思ったのが今回の記事になります。
「エントリ枠に申し込む」、「抽選結果を登録する」、「入金する」の3つのエンドポイントのうち、「エントリ枠に申し込む」を今回はリファクタリングしました。
ソースコードは こちら
本記事投稿時のバージョンはタグ1.1になります。
ドメインモデル図を作成
モデリングが上手くできていないと感じていたので、@little_hand_s さんの DDDのモデリングとは何なのか、 そしてどうコードに落とすのか というスライドを参考にドメインモデル図を作成してみました。
試験問題からビジネスルールに該当する部分を吹き出しにしています。
これら吹き出しがすべて今回のエントリポイントのプログラムに関係する部分ではないですが、ドメイン層に書くべきロジックが整理できて良かったです。
アプリケーション層からドメイン層に
「会員は、一つの大会について一つのエントリ枠だけ参加申込できる」の部分が前回のプログラムではアプリケーション層に漏れていて、これをドメイン層に移動することにトライしてみました。
前回のコメント欄にて、@a-suenami さんに
たとえば「一つの大会について一つのエントリ枠だけ参加申し込みできる」という条件はドメインの関心事だと思うのでアプリケーションサービスで if にするのでなく、大会応募可否ポリシー(FestivalApplicationPolicy)のような型を作ってドメインモデルの中に入れたほうがいいと思いますね。
という意見をいただき、これを自分なりに実装してみました。
public class FestivalApplicationPolicy {
private List<Application> applicationList;
public FestivalApplicationPolicy(List<Application> applicationList) {
this.applicationList = new ArrayList<>(applicationList);
}
/**
* 引数で指定した会員が、引数で指定した大会に既に参加申込しているかどうかを返す。
* 既に申し込み済みの場合、 true を返す。
*/
boolean hasAlreadyApplyForSameFestival(MemberId memberId, FestivalId festivalId) {
for (Application application : applicationList) {
if (application.festivalId().equals(festivalId)
&& application.memberId().equals(memberId)) {
return true;
}
}
return false;
}
}
ファーストクラスコレクションを使っています。
フィールドに参加申込のリストを持ち、このなかに、会員番号と大会番号が同じ参加申込があれば、既に参加申込済みと判定するようにしてみました。
これを後述するドメインサービスで使うようにしました。
ドメインサービスの作成
参加申込を永続化するためのエンティティを作成する必要があり、そのエンティティを作成するドメインサービスを作ってみました。(名前が紛らわしいのですが、アプリケーション層ではなくドメイン層に作成しています)
ドメインサービスのうまい使い方ってまだ良く分かっていなく、参加申込のエンティティにstaticメソッドで作るか迷いましたが、今回はこのようにしました。
public class ApplicationService {
private Entry entry;
private FestivalApplicationPolicy festivalApplicationPolicy;
/**
* コンストラクタ.
*/
public ApplicationService(
Entry entry, FestivalApplicationPolicy festivalApplicationPolicy) {
this.entry = entry;
this.festivalApplicationPolicy = festivalApplicationPolicy;
}
/**
* 参加申込オブジェクトを生成して返す.
* @return 参加申込オブジェクト
*/
public Application createApplication(MemberId memberId, LocalDate applicationDate)
throws EntryStatusIsNotRecruitingException,
HasAlreadyApplyForSameFestivalException {
// エントリ枠が募集中の間だけ参加申込を受け付ける
if (entry.entryStatus() != EntryStatus.recruiting) {
throw new EntryStatusIsNotRecruitingException();
}
// 募集開始日と募集終了日での判定は、本来、エントリ枠状態が募集中であれば、
// エラーは発生しえないはずなので、ここではあえて、IllegalStateException をスローしています。
if (applicationDate.compareTo(entry.applicationStartDate()) < 0) {
throw new IllegalStateException("指定した大会はまだ募集を開始していません");
}
if (applicationDate.compareTo(entry.applicationEndDate()) > 0) {
throw new IllegalStateException("指定した大会の募集期間を過ぎています");
}
// 会員は一つの大会について一つのエントリ枠だけ参加申込できる
if (festivalApplicationPolicy.hasAlreadyApplyForSameFestival(
memberId, entry.festivalId())) {
throw new HasAlreadyApplyForSameFestivalException();
}
return Application.createEntityForEntry(
entry.festivalId(),
memberId,
entry.entryId(),
applicationDate
);
}
}
「エントリ枠が募集中の間だけ参加申込を受け付ける」、「会員は一つの大会について一つのエントリ枠だけ参加申込できる」などのチェックをしたのちに、エラーがなければ、永続化対象の Applicationクラスのオブジェクトを生成して返しています。
また、EntryStatusIsNotRecruitingException
とHasAlreadyApplyForSameFestivalException
という業務例外のクラスを作っています。これも、 @a-suenami さんから頂いた
ユーザに見せるメッセージの文字列自体はアプリケーションサービスでハンドリングしたいですが、どういう種類のエラーがあるのかはドメインオブジェクトになりえると思うので列挙型で返すとかにしたい気がします。
というコメントを参考にトライしてみました。
ただし、列挙型ではなく例外を使っています。
業務例外クラスのフィールドにエラーの種類が分かる列挙型を持つか迷ったんですが、この二つのエラーも参加申込ドメインの関心と考えて、参加申込のドメインパッケージに例外クラスを作ってみました。
この例外をアプリケーション層でキャッチして、エラーメッセージをハンドリングできるようにしています。
アプリケーション層の比較
前回と今回のアプリケーション層の参加申込のプログラムは以下の通りです。
前回
/**
* エントリー枠に申し込む.
*/
public void applyForEntry(ApplyForEntryRequest request) {
final FestivalId festivalId = request.festivalId();
final MemberId memberId = request.memberId();
final EntryId entryId = request.entryId();
final LocalDate applicationDate = request.getApplicationDate();
final Member member = memberRepository.findMember(request.memberId());
if (member == null) {
throw new BusinessErrorException("存在しない会員です");
}
final Application alreadyApplication =
applicationRepository.findApplication(festivalId, memberId);
if (alreadyApplication != null) {
throw new BusinessErrorException("指定した大会には既に申し込み済みです");
}
final Entry entry = entryRepository.findEntry(festivalId, entryId);
if (entry == null) {
throw new BusinessErrorException("存在しないエントリ枠です");
}
final Application application = Application.createEntityForEntry(
festivalId,
memberId,
entryId,
applicationDate
);
entry.validateAndThrowBusinessErrorIfHasErrorForApplication(application);
entry.incrementApplicationNumbers();
entryRepository.saveEntry(entry);
applicationRepository.addApplication(application);
}
今回
/**
* エントリー枠に申し込む.
*/
public void applyForEntry(ApplyForEntryRequest request) {
final FestivalId festivalId = request.festivalId();
final EntryId entryId = request.entryId();
final MemberId memberId = request.memberId();
final LocalDate applicationDate = request.getApplicationDate();
final Member member = memberRepository.findMember(memberId);
if (member == null) {
throw new BusinessErrorException("存在しない会員です");
}
final Entry entry = entryRepository.findEntry(festivalId, entryId);
if (entry == null) {
throw new BusinessErrorException("存在しないエントリ枠です");
}
final FestivalApplicationPolicy festivalApplicationPolicy =
applicationRepository.createFestivalApplicationPolicy(festivalId, memberId);
final ApplicationService applicationService =
new ApplicationService(entry, festivalApplicationPolicy);
final Application application;
try {
application = applicationService.createApplication(memberId, applicationDate);
} catch (EntryStatusIsNotRecruitingException e) {
throw new BusinessErrorException("指定した大会は現在募集を行っておりません");
} catch (HasAlreadyApplyForSameFestivalException e) {
throw new BusinessErrorException("指定した大会には既に申し込み済みです");
}
entry.incrementApplicationNumbers();
entryRepository.saveEntry(entry);
applicationRepository.addApplication(application);
}
ドメインサービスのApplicationService
をエントリー枠のオブジェクトEntry
と今回新しく作成したFestivalApplicationPolicy
を引数に生成し、ApplicationService
で参加申込のエンティティApplicaiton
を生成して、これを永続化するようにしました。
あまり大きな変化はないように見えますが、少なくとも「会員は一つの大会について一つのエントリ枠だけ参加申込できる」をドメイン層に移動することができました。
さいごに
今回の場合、あまり恩恵はないかもしれませんが、ドメイン層にドメインの関心ごとをもっていく改善を繰り返して、実際の業務では、変更しやすいプログラムに!!な、はずです
また、リファクタリングで改善を繰り返すことで、設計のスキルが上がっていくはずなので、「抽選結果を登録する」と「入金する」のエントリポイントも近日中にリファクタリングして記事を書こうと思います。
ここまで読んでくださり、ありがとうございました。
みなさんからのコメントとても嬉しく、学びもたくさんあるので、コメントいただけると幸いです。